From 838ff646462d96e711289d513a4fd51b10ae734a Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 4 Aug 2026 18:44:01 +0200 Subject: [PATCH 01/21] Add githubv3 package first pass First pass implementation of internal/githubv3 from the design spec, for review. No production callers: nothing outside this package is touched, and api.Client, the factory, ghcmd and httpmock are all untouched. Client holds an already-resolved API base URL. NewRequest sets Authorization then applies options so options can override. Send leaves the body open and closes it on the error path. Do decodes and closes, skipping 204 and 205 and discarding a nil v. Non-2xx yields *ErrorResponse, whose ScopesSuggestion is computed rather than stored and is nil-safe. Once Do has received a response successfully it always returns it, even alongside an error, on all three of its body-handling failure paths. The request succeeded and the status and headers are known good, so only body handling failed. This matches google/go-github's Do. Send is unchanged: its non-2xx path still returns a nil response, losing nothing because *ErrorResponse carries StatusCode and Headers, and its transport-error path has no response to return. A token is supplied by WithClientToken rather than as a constructor argument, and its absence means "let the transport supply credentials", never "send unauthenticated". There is deliberately no way to express "send no credentials". An empty token still sets the header, because api.AddAuthTokenHeader and go-gh's header round tripper stand down only when Authorization is non-empty, so a client that omits the option authenticates as the active user. That is a live wrong-answer bug for gh auth status, which needs one specific user's token even when it is empty. Fixing the transport so it cannot override a client's intent is follow-up. WithCheckRedirect carries the client-level redirect policy that gh release download and gh repo delete need and that a request-scoped option cannot express. It shallow-copies the http.Client, because NewClient stores the caller's and the CLI shares one across commands, so setting the field in place would install a redirect policy process-wide. Both call sites already hand-roll the same copy. NewErrorResponse is exported for callers that already hold a response and need the error for it, which is what api.HandleHTTPError does. There is one parser rather than an exported and an unexported form, because two entry points offering different guarantees about whether Response.Request is settled is the shape that produced the nil-deref hazard it guards against. Send now settles Request itself, on a response that is its own, and NewErrorResponse parses a shallow copy when Request is nil so a caller's response is never mutated. RequestURL is left nil rather than an empty *url.URL, which would satisfy every dereference and then print as a blank but real-looking URL. Two knowing divergences from the migration plan's Task 1, recorded here so they stay visible rather than being discovered at the call sites: The design spec has not been patched to match this package, so the spec remains the record of the design conversation rather than of the code. Task 1 names NewClient(apiBaseURL *url.URL, ...) *Client. This takes a string and returns an error instead, so an invalid base URL is rejected at construction and a Client either resolves relative paths correctly or does not exist. A caller holding a parsed URL passes u.String(). Task 4's factory resolver will not match the plan's snippet. Six decisions the spec did not cover: Relative paths are joined textually rather than with url.URL.JoinPath, which percent-escapes the query separator and would break every paginated or filtered call site. net/http only populates Response.Request for its own transport, so Send backfills it from the outgoing request. go-gh's HandleHTTPError dereferences resp.Request.URL unguarded, so this would otherwise panic rather than return an error. Error() reproduces go-gh's HTTPError.Error() format so error text does not change when api.HTTPError is later replaced. NewClient rejects an empty, unparseable or relative base URL, so a client either resolves relative paths correctly or does not exist. An empty response body with a non-nil v stays an error, matching api.Client.REST and go-gh rather than google/go-github, which tolerates it. Both the code and a test say so, since changing it would silently alter behaviour at every existing call site. WithToken, the request-scoped option, now also always sets the header rather than deleting it when empty, mirroring WithClientToken. Only the client-level option was ruled on, but the same wrong-answer hazard applies, and leaving them opposed would make the package contradict its own documented rule. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 195575d5-99df-498d-bcfb-237299cac2d4 --- internal/githubv3/client.go | 288 +++++++++++ internal/githubv3/client_test.go | 795 +++++++++++++++++++++++++++++ internal/githubv3/errors.go | 172 +++++++ internal/githubv3/errors_test.go | 340 ++++++++++++ internal/githubv3/response.go | 27 + internal/githubv3/response_test.go | 65 +++ 6 files changed, 1687 insertions(+) create mode 100644 internal/githubv3/client.go create mode 100644 internal/githubv3/client_test.go create mode 100644 internal/githubv3/errors.go create mode 100644 internal/githubv3/errors_test.go create mode 100644 internal/githubv3/response.go create mode 100644 internal/githubv3/response_test.go diff --git a/internal/githubv3/client.go b/internal/githubv3/client.go new file mode 100644 index 00000000000..ce0bc1eda58 --- /dev/null +++ b/internal/githubv3/client.go @@ -0,0 +1,288 @@ +// Package githubv3 is a client for GitHub's REST (v3) API. It is unrelated to +// github.com/shurcooL/githubv4, which is a GraphQL query DSL rather than a client. +package githubv3 + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +const authorizationHeader = "Authorization" + +// Client sends requests to a single GitHub REST API host. +// +// It holds an already-resolved API base URL: it reads no configuration and has +// no opinion about how a canonical host such as github.example.com becomes an +// API host. That resolution happens once, where the client is constructed. +// +// A token is optional, and its absence means "let the transport supply +// credentials", never "send an unauthenticated request". See WithClientToken. +type Client struct { + apiBaseURL *url.URL + token *string + http *http.Client +} + +// ClientOption configures a Client at construction. +type ClientOption func(*Client) + +// WithClientToken sets the token the client authenticates with, and is the only +// way to give a client a token. +// +// It always sets the Authorization header, including when t is empty, so the +// client authenticates as exactly the given token and nothing else. Omitting +// this option does not mean "unauthenticated": it means the header is left +// unset, and whatever credentials the transport supplies are used instead. The +// package deliberately offers no way to express "send no credentials", because +// no caller needs it and the transport would override it anyway. +// +// Callers such as gh auth status, which check one specific user's token rather +// than the host's active one, must pass this option even when the token they +// hold is empty. Without it, api.AddAuthTokenHeader steps in whenever +// Authorization is unset and authenticates as the *active* user, so the command +// would report on the wrong credentials. That is why an empty t still sets the +// header: the value is rendered as "token ", which is non-empty, and both +// AddAuthTokenHeader and go-gh's header round tripper stand down only when +// Authorization is non-empty. A header set to "" would not stand them down. +// +// Forgetting this option is therefore a silent wrong-answer hazard rather than +// an error. Containing it here is deliberate; fixing the transport so that it +// cannot override a client's intent is tracked separately. +func WithClientToken(t string) ClientOption { + return func(c *Client) { + c.token = &t + } +} + +// WithCheckRedirect sets the redirect policy for the client's requests. +// +// It is needed by callers that rewrite a redirect target, such as gh release +// download avoiding legacy Codeload paths, and by callers that halt redirects +// with http.ErrUseLastResponse, such as gh repo delete. Neither is expressible +// through a RequestOption, since a redirect policy belongs to the http.Client +// rather than to a request. +// +// The http.Client is shallow-copied and the policy set on the copy. This is not +// ceremony: NewClient stores the caller's *http.Client, and the CLI hands the +// same one to every command, so setting the field in place would install this +// policy process-wide for unrelated commands. Both call sites named above +// already hand-roll the same copy for the same reason. +func WithCheckRedirect(fn func(*http.Request, []*http.Request) error) ClientOption { + return func(c *Client) { + httpClient := &http.Client{} + if c.http != nil { + copied := *c.http + httpClient = &copied + } + httpClient.CheckRedirect = fn + c.http = httpClient + } +} + +// NewClient returns a Client that sends requests to apiBaseURL through +// httpClient. +// +// apiBaseURL is a resolved, absolute API base URL such as +// https://api.github.com/ or https://github.example.com/api/v3/, not a +// canonical host. It is rejected here rather than per request, so a Client +// either resolves relative paths correctly or does not exist. +// +// This takes a string and returns an error where the migration plan's Task 1 +// names apiBaseURL *url.URL and no error. The invariant is worth more than +// saving a caller a url.Parse, so a caller holding a parsed URL passes +// u.String(). +// +// Without WithClientToken the client sets no Authorization header, which means +// the transport supplies credentials rather than that requests carry none. +func NewClient(apiBaseURL string, httpClient *http.Client, opts ...ClientOption) (*Client, error) { + if apiBaseURL == "" { + return nil, errors.New("githubv3: apiBaseURL is required") + } + + parsed, err := url.Parse(apiBaseURL) + if err != nil { + return nil, fmt.Errorf("githubv3: parsing apiBaseURL: %w", err) + } + if parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("githubv3: apiBaseURL %q is not absolute", apiBaseURL) + } + + client := &Client{ + apiBaseURL: parsed, + http: httpClient, + } + + for _, opt := range opts { + opt(client) + } + + return client, nil +} + +// RequestOption modifies a request built by NewRequest. +// +// It receives the *http.Request itself, so it is not limited to single-valued +// headers, and a caller who needs something no option covers can mutate the +// request between NewRequest and Send. +type RequestOption func(*http.Request) + +// WithHeader sets a request header, replacing any existing values for name. +func WithHeader(name, value string) RequestOption { + return func(req *http.Request) { + req.Header.Set(name, value) + } +} + +// WithToken overrides the client's token for a single request. +// +// It always sets the Authorization header, including when token is empty, +// mirroring WithClientToken: the request authenticates as exactly this token. +// Omitting it leaves the client's own token in place. There is deliberately no +// way to express "send no credentials" for the reasons given at +// WithClientToken. +func WithToken(token string) RequestOption { + return func(req *http.Request) { + req.Header.Set(authorizationHeader, authorizationValue(token)) + } +} + +// authorizationValue renders a token as an Authorization header value. +// +// An empty token still yields a non-empty value, which is what makes +// api.AddAuthTokenHeader and go-gh's header round tripper stand down: both +// check only whether Authorization is non-empty. +func authorizationValue(token string) string { + return fmt.Sprintf("token %s", token) +} + +// NewRequest builds a request against the client's host. +// +// path may be a path relative to the client's API base URL, or an absolute +// URL. Absolute URLs are required by callers that follow server-supplied URLs, +// such as release asset uploads. +// +// The Authorization header is set from the client's token, when it has one, +// before opts are applied, so an option can override it. A client with no token +// leaves the header unset for the transport to fill in. +// +// No other headers are set here. User-Agent, X-GitHub-Api-Version and, unless +// they are skipped, Accept, Content-Type and Time-Zone are applied by go-gh's +// header round tripper at RoundTrip time, and it skips any header already +// present on the request. An option therefore wins by being set earlier, not +// later, and setting defaults here would double-set them. +func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Reader, opts ...RequestOption) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx, method, c.resolveURL(path), body) + if err != nil { + return nil, err + } + + if c.token != nil { + req.Header.Set(authorizationHeader, authorizationValue(*c.token)) + } + + for _, opt := range opts { + opt(req) + } + + return req, nil +} + +// Send issues req and returns the response with its body still open, so the +// caller must close it. +// +// A non-2xx status yields a nil *Response and an *ErrorResponse. The body is +// closed on that path, because a caller holding no response has nothing to +// close. +func (c *Client) Send(req *http.Request) (*Response, error) { + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + + // net/http only populates Response.Request for its own transport, so a + // custom RoundTripper can leave it nil. Settling it here rather than in + // NewErrorResponse means the parser has one guarantee to reason about + // instead of two, and this response is ours to mutate. + if resp.Request == nil { + resp.Request = req + } + + if !isSuccess(resp.StatusCode) { + defer resp.Body.Close() + return nil, NewErrorResponse(resp) + } + + return &Response{Response: resp}, nil +} + +// Do issues req, decodes the JSON body into v, and closes the body. +// +// A nil v discards the body without decoding it. Statuses 204 and 205 skip +// decoding, since they carry no content. +// +// Once a response has been received successfully it is always returned, even +// alongside an error: the request succeeded and the status and headers are +// known good, so only body handling failed and the caller should keep them. +// The body of such a response is already closed, since Do closes it +// unconditionally, so only its metadata is readable. +// +// A non-2xx status yields a nil *Response and an *ErrorResponse. +func (c *Client) Do(req *http.Request, v any) (*Response, error) { + resp, err := c.Send(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusResetContent { + return resp, nil + } + + if v == nil { + // Drained rather than ignored so the connection can be reused. + _, err := io.Copy(io.Discard, resp.Body) + if err != nil { + return resp, err + } + return resp, nil + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return resp, err + } + + // An empty body reaches json.Unmarshal and fails with "unexpected end of + // JSON input". That is deliberate: it is what api.Client.REST and go-gh do + // today, so tolerating it the way google/go-github does would silently + // change behaviour at every existing call site. + if err := json.Unmarshal(b, v); err != nil { + return resp, err + } + + return resp, nil +} + +// resolveURL joins path to the client's API base URL, passing absolute URLs +// through untouched. +// +// The join is textual rather than url.URL.JoinPath, which percent-escapes the +// query separator and so would turn "issues?state=open" into +// "issues%3Fstate=open", silently breaking every filtered and paginated call +// site. go-gh concatenates for the same reason. +func (c *Client) resolveURL(path string) string { + if strings.HasPrefix(path, "https://") || strings.HasPrefix(path, "http://") { + return path + } + return strings.TrimSuffix(c.apiBaseURL.String(), "/") + "/" + strings.TrimPrefix(path, "/") +} + +func isSuccess(statusCode int) bool { + return statusCode >= 200 && statusCode < 300 +} diff --git a/internal/githubv3/client_test.go b/internal/githubv3/client_test.go new file mode 100644 index 00000000000..f552650f5cf --- /dev/null +++ b/internal/githubv3/client_test.go @@ -0,0 +1,795 @@ +package githubv3 + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + ghAPI "github.com/cli/go-gh/v2/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// roundTripFunc adapts a function to http.RoundTripper. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +// trackedBody records whether it has been closed. +type trackedBody struct { + io.Reader + mu sync.Mutex + closed bool +} + +func newTrackedBody(s string) *trackedBody { + return &trackedBody{Reader: strings.NewReader(s)} +} + +func (b *trackedBody) Close() error { + b.mu.Lock() + defer b.mu.Unlock() + b.closed = true + return nil +} + +func (b *trackedBody) isClosed() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.closed +} + +// failingBody fails on read, standing in for a connection that drops partway +// through a response body. +type failingBody struct{} + +func newFailingBody() io.ReadCloser { + return failingBody{} +} + +func (failingBody) Read([]byte) (int, error) { + return 0, errors.New("connection reset while reading body") +} + +func (failingBody) Close() error { + return nil +} + +// stubResponse builds a response for the given request, as net/http's own +// transport does, including setting Request. +func stubResponse(req *http.Request, status int, body io.ReadCloser, header http.Header) *http.Response { + if header == nil { + header = http.Header{} + } + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Header: header, + Body: body, + Request: req, + } +} + +// stubClient returns a client whose transport always replies with resp, along +// with a pointer that captures the request that was sent. +func stubClient(t *testing.T, baseURL, token string, respFn func(*http.Request) *http.Response) (*Client, **http.Request) { + t.Helper() + + var sent *http.Request + httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + sent = req + return respFn(req), nil + })} + + client, err := NewClient(baseURL, httpClient, WithClientToken(token)) + require.NoError(t, err) + + return client, &sent +} + +// newTestClient builds a client for the common case, failing the test if the +// base URL is not usable. +func newTestClient(t *testing.T, baseURL string, httpClient *http.Client, opts ...ClientOption) *Client { + t.Helper() + + client, err := NewClient(baseURL, httpClient, opts...) + require.NoError(t, err) + return client +} + +func TestNewRequestURL(t *testing.T) { + tests := []struct { + name string + baseURL string + path string + wantURL string + }{ + { + name: "relative path", + baseURL: "https://api.github.com/", + path: "repos/OWNER/REPO", + wantURL: "https://api.github.com/repos/OWNER/REPO", + }, + { + name: "relative path against a base URL without a trailing slash", + baseURL: "https://api.github.com", + path: "repos/OWNER/REPO", + wantURL: "https://api.github.com/repos/OWNER/REPO", + }, + { + name: "relative path with a leading slash", + baseURL: "https://api.github.com/", + path: "/repos/OWNER/REPO", + wantURL: "https://api.github.com/repos/OWNER/REPO", + }, + { + name: "relative path against an enterprise base URL", + baseURL: "https://github.example.com/api/v3/", + path: "repos/OWNER/REPO", + wantURL: "https://github.example.com/api/v3/repos/OWNER/REPO", + }, + { + name: "relative path with a query string", + baseURL: "https://api.github.com/", + path: "repos/OWNER/REPO/issues?state=open&per_page=100", + wantURL: "https://api.github.com/repos/OWNER/REPO/issues?state=open&per_page=100", + }, + { + name: "absolute https URL ignores the base URL", + baseURL: "https://api.github.com/", + path: "https://uploads.github.com/repos/OWNER/REPO/releases/1/assets?name=x", + wantURL: "https://uploads.github.com/repos/OWNER/REPO/releases/1/assets?name=x", + }, + { + name: "absolute http URL ignores the base URL", + baseURL: "https://api.github.com/", + path: "http://api.github.localhost/repos/OWNER/REPO", + wantURL: "http://api.github.localhost/repos/OWNER/REPO", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := newTestClient(t, tt.baseURL, nil) + + req, err := client.NewRequest(context.Background(), http.MethodGet, tt.path, nil) + require.NoError(t, err) + + assert.Equal(t, tt.wantURL, req.URL.String()) + }) + } +} + +func TestNewRequestAuthorization(t *testing.T) { + tests := []struct { + name string + clientOpts []ClientOption + opts []RequestOption + wantAuth string + wantAuthSet bool + }{ + { + name: "a client with no token leaves Authorization unset for the transport", + wantAuthSet: false, + }, + { + name: "WithClientToken sets Authorization", + clientOpts: []ClientOption{WithClientToken("CLIENT-TOKEN")}, + wantAuth: "token CLIENT-TOKEN", + wantAuthSet: true, + }, + { + name: "WithClientToken with an empty token still sets Authorization", + clientOpts: []ClientOption{WithClientToken("")}, + wantAuth: "token ", + wantAuthSet: true, + }, + { + name: "WithToken overrides the client token", + clientOpts: []ClientOption{WithClientToken("CLIENT-TOKEN")}, + opts: []RequestOption{WithToken("OTHER-TOKEN")}, + wantAuth: "token OTHER-TOKEN", + wantAuthSet: true, + }, + { + name: "WithToken sets Authorization on a client with no token", + opts: []RequestOption{WithToken("OTHER-TOKEN")}, + wantAuth: "token OTHER-TOKEN", + wantAuthSet: true, + }, + { + name: "WithToken with an empty token still sets Authorization", + clientOpts: []ClientOption{WithClientToken("CLIENT-TOKEN")}, + opts: []RequestOption{WithToken("")}, + wantAuth: "token ", + wantAuthSet: true, + }, + { + name: "WithHeader can override Authorization", + clientOpts: []ClientOption{WithClientToken("CLIENT-TOKEN")}, + opts: []RequestOption{WithHeader("Authorization", "SET-BY-HEADER")}, + wantAuth: "SET-BY-HEADER", + wantAuthSet: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := newTestClient(t, "https://api.github.com/", nil, tt.clientOpts...) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil, tt.opts...) + require.NoError(t, err) + + _, ok := req.Header["Authorization"] + assert.Equal(t, tt.wantAuthSet, ok) + assert.Equal(t, tt.wantAuth, req.Header.Get("Authorization")) + }) + } +} + +// TestEmptyTokenStandsDownTheTransport pins the mechanism that makes an empty +// token mean "authenticate as exactly this token" rather than "let the +// transport decide". api.AddAuthTokenHeader and go-gh's header round tripper +// both stand down only when Header.Get("Authorization") is non-empty, so a +// header set to "" would not stop either of them injecting the active user's +// token, and a command such as gh auth status would report on the wrong +// credentials. +func TestEmptyTokenStandsDownTheTransport(t *testing.T) { + tests := []struct { + name string + client []ClientOption + req []RequestOption + }{ + { + name: "client token", + client: []ClientOption{WithClientToken("")}, + }, + { + name: "request token", + req: []RequestOption{WithToken("")}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := newTestClient(t, "https://api.github.com/", nil, tt.client...) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil, tt.req...) + require.NoError(t, err) + + assert.NotEmpty(t, req.Header.Get("Authorization"), + "an empty Authorization value would let the transport inject the active user's token") + }) + } +} + +func TestNewClient(t *testing.T) { + t.Run("rejects an empty base URL", func(t *testing.T) { + _, err := NewClient("", nil) + require.Error(t, err) + }) + + t.Run("rejects a relative base URL", func(t *testing.T) { + _, err := NewClient("api.github.com", nil) + require.Error(t, err) + }) + + t.Run("rejects an unparseable base URL", func(t *testing.T) { + _, err := NewClient("https://api.github.com/\x7f", nil) + require.Error(t, err) + }) + + t.Run("accepts an absolute base URL", func(t *testing.T) { + client, err := NewClient("https://api.github.com/", nil) + require.NoError(t, err) + assert.NotNil(t, client) + }) +} + +func TestNewRequestOptions(t *testing.T) { + client := newTestClient(t, "https://api.github.com/", nil) + + t.Run("WithHeader sets a header", func(t *testing.T) { + req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil, + WithHeader("Accept", "application/vnd.github.v3.diff")) + require.NoError(t, err) + + assert.Equal(t, "application/vnd.github.v3.diff", req.Header.Get("Accept")) + }) + + t.Run("options are applied in order", func(t *testing.T) { + req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil, + WithHeader("Accept", "first"), + WithHeader("Accept", "second")) + require.NoError(t, err) + + assert.Equal(t, []string{"second"}, req.Header.Values("Accept")) + }) + + t.Run("a caller can add a second value for a header", func(t *testing.T) { + req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil, + WithHeader("Accept", "first"), + func(req *http.Request) { req.Header.Add("Accept", "second") }) + require.NoError(t, err) + + assert.Equal(t, []string{"first", "second"}, req.Header.Values("Accept")) + }) + + t.Run("the request carries the context", func(t *testing.T) { + type ctxKey struct{} + ctx := context.WithValue(context.Background(), ctxKey{}, "value") + + req, err := client.NewRequest(ctx, http.MethodGet, "user", nil) + require.NoError(t, err) + + assert.Equal(t, "value", req.Context().Value(ctxKey{})) + }) + + t.Run("an invalid method is an error", func(t *testing.T) { + _, err := client.NewRequest(context.Background(), "bad method", "user", nil) + require.Error(t, err) + }) +} + +// TestRequestOptionSurvivesGoGHDefaultHeaders covers the mechanism that gives +// options precedence over defaults: go-gh's header round tripper skips any +// header already present on the request, so setting one in NewRequest wins even +// though the defaults are applied later, at RoundTrip time. +func TestRequestOptionSurvivesGoGHDefaultHeaders(t *testing.T) { + var sent *http.Request + recorder := roundTripFunc(func(req *http.Request) (*http.Response, error) { + sent = req + return stubResponse(req, http.StatusOK, io.NopCloser(strings.NewReader("{}")), nil), nil + }) + + httpClient, err := ghAPI.NewHTTPClient(ghAPI.ClientOptions{ + Host: "github.com", + AuthToken: "GO-GH-TOKEN", + Transport: recorder, + LogIgnoreEnv: true, + }) + require.NoError(t, err) + + client := newTestClient(t, "https://api.github.com/", httpClient, WithClientToken("CLIENT-TOKEN")) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil, + WithHeader("Accept", "application/vnd.github.v3.diff")) + require.NoError(t, err) + + _, err = client.Do(req, nil) + require.NoError(t, err) + + require.NotNil(t, sent) + assert.Equal(t, "application/vnd.github.v3.diff", sent.Header.Get("Accept")) + assert.Equal(t, "token CLIENT-TOKEN", sent.Header.Get("Authorization")) + assert.NotEmpty(t, sent.Header.Get("User-Agent"), "go-gh should still supply defaults we did not set") +} + +func TestSend(t *testing.T) { + t.Run("leaves the body open for the caller", func(t *testing.T) { + body := newTrackedBody(`{"name":"REPO"}`) + client, sent := stubClient(t, "https://api.github.com/", "TOKEN", func(req *http.Request) *http.Response { + return stubResponse(req, http.StatusOK, body, nil) + }) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "repos/OWNER/REPO", nil) + require.NoError(t, err) + + resp, err := client.Send(req) + require.NoError(t, err) + require.NotNil(t, *sent) + + assert.False(t, body.isClosed()) + + read, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, `{"name":"REPO"}`, string(read)) + + require.NoError(t, resp.Body.Close()) + assert.True(t, body.isClosed()) + }) + + t.Run("exposes the success status code", func(t *testing.T) { + client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + return stubResponse(req, http.StatusCreated, io.NopCloser(strings.NewReader("{}")), nil) + }) + + req, err := client.NewRequest(context.Background(), http.MethodPost, "repos/OWNER/REPO/issues", nil) + require.NoError(t, err) + + resp, err := client.Send(req) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusCreated, resp.StatusCode) + }) + + t.Run("closes the body on the error path", func(t *testing.T) { + body := newTrackedBody(`{"message":"Not Found"}`) + client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + header := http.Header{"Content-Type": []string{"application/json"}} + return stubResponse(req, http.StatusNotFound, body, header) + }) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "repos/OWNER/REPO", nil) + require.NoError(t, err) + + resp, err := client.Send(req) + require.Error(t, err) + assert.Nil(t, resp) + assert.True(t, body.isClosed(), "Send must close the body when the caller gets no response to close") + }) + + t.Run("returns transport errors unchanged", func(t *testing.T) { + wantErr := assert.AnError + httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, wantErr + })} + client := newTestClient(t, "https://api.github.com/", httpClient) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil) + require.NoError(t, err) + + resp, err := client.Send(req) + require.ErrorIs(t, err, wantErr) + assert.Nil(t, resp) + + var errResp *ErrorResponse + assert.False(t, errors.As(err, &errResp), "a transport failure is not an API error response") + }) +} + +func TestDo(t *testing.T) { + t.Run("decodes the body and closes it", func(t *testing.T) { + body := newTrackedBody(`{"name":"REPO"}`) + client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + return stubResponse(req, http.StatusOK, body, nil) + }) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "repos/OWNER/REPO", nil) + require.NoError(t, err) + + var target struct { + Name string `json:"name"` + } + resp, err := client.Do(req, &target) + require.NoError(t, err) + + assert.Equal(t, "REPO", target.Name) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, body.isClosed()) + }) + + t.Run("a nil v discards the body without decoding", func(t *testing.T) { + body := newTrackedBody("this is not JSON") + client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + return stubResponse(req, http.StatusOK, body, nil) + }) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "repos/OWNER/REPO", nil) + require.NoError(t, err) + + resp, err := client.Do(req, nil) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.True(t, body.isClosed()) + }) + + t.Run("a malformed body is an error that still returns the response", func(t *testing.T) { + client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + return stubResponse(req, http.StatusOK, io.NopCloser(strings.NewReader("not JSON")), nil) + }) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "repos/OWNER/REPO", nil) + require.NoError(t, err) + + var target struct{} + resp, err := client.Do(req, &target) + require.Error(t, err) + require.NotNil(t, resp) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + // The request succeeded and the status and headers are known good on each + // of these paths, so only body handling failed and the caller keeps the + // response. + t.Run("returns the response alongside every post-success body error", func(t *testing.T) { + tests := []struct { + name string + body func() io.ReadCloser + v any + }{ + { + name: "decode failure", + body: func() io.ReadCloser { return io.NopCloser(strings.NewReader("not JSON")) }, + v: &struct{}{}, + }, + { + name: "read failure", + body: func() io.ReadCloser { return newFailingBody() }, + v: &struct{}{}, + }, + { + name: "drain failure on the nil v path", + body: func() io.ReadCloser { return newFailingBody() }, + v: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + return stubResponse(req, http.StatusCreated, tt.body(), nil) + }) + + req, err := client.NewRequest(context.Background(), http.MethodPost, "repos/OWNER/REPO/issues", nil) + require.NoError(t, err) + + resp, err := client.Do(req, tt.v) + require.Error(t, err) + require.NotNil(t, resp, "a response that was received successfully must be returned") + assert.Equal(t, http.StatusCreated, resp.StatusCode) + }) + } + }) + + // api.Client.REST and go-gh both surface this as an error today. + // google/go-github swallows it instead, and matching that would silently + // change behaviour at every existing call site. + t.Run("an empty body with a non-nil v is an error", func(t *testing.T) { + client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + return stubResponse(req, http.StatusOK, io.NopCloser(strings.NewReader("")), nil) + }) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "repos/OWNER/REPO", nil) + require.NoError(t, err) + + var target struct{} + resp, err := client.Do(req, &target) + require.EqualError(t, err, "unexpected end of JSON input") + require.NotNil(t, resp) + }) + + t.Run("skips decoding on statuses without content", func(t *testing.T) { + tests := []struct { + name string + status int + }{ + {name: "204 No Content", status: http.StatusNoContent}, + {name: "205 Reset Content", status: http.StatusResetContent}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := newTrackedBody("this is not JSON") + client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + return stubResponse(req, tt.status, body, nil) + }) + + req, err := client.NewRequest(context.Background(), http.MethodDelete, "repos/OWNER/REPO", nil) + require.NoError(t, err) + + var target struct{} + resp, err := client.Do(req, &target) + require.NoError(t, err) + + assert.Equal(t, tt.status, resp.StatusCode) + assert.True(t, body.isClosed()) + }) + } + }) + + t.Run("a non-2xx status yields an ErrorResponse", func(t *testing.T) { + client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + header := http.Header{"Content-Type": []string{"application/json"}} + body := io.NopCloser(strings.NewReader(`{ + "message": "Validation Failed", + "errors": [{"resource":"Issue","field":"title","code":"missing_field"}] + }`)) + return stubResponse(req, http.StatusUnprocessableEntity, body, header) + }) + + req, err := client.NewRequest(context.Background(), http.MethodPost, "repos/OWNER/REPO/issues", nil) + require.NoError(t, err) + + var target struct{} + resp, err := client.Do(req, &target) + require.Error(t, err) + assert.Nil(t, resp) + + var errResp *ErrorResponse + require.ErrorAs(t, err, &errResp) + assert.Equal(t, http.StatusUnprocessableEntity, errResp.StatusCode) + assert.Equal(t, "Validation Failed\nIssue.title is missing", errResp.Message) + assert.Equal(t, []ErrorItem{{Resource: "Issue", Field: "title", Code: "missing_field"}}, errResp.Errors) + assert.Equal(t, "https://api.github.com/repos/OWNER/REPO/issues", errResp.RequestURL.String()) + assert.Equal(t, "application/json", errResp.Headers.Get("Content-Type")) + }) + + t.Run("a non-JSON error body falls back to the status", func(t *testing.T) { + client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + header := http.Header{"Content-Type": []string{"text/plain"}} + return stubResponse(req, http.StatusBadGateway, io.NopCloser(strings.NewReader("oh no")), header) + }) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil) + require.NoError(t, err) + + _, err = client.Do(req, nil) + + var errResp *ErrorResponse + require.ErrorAs(t, err, &errResp) + assert.Equal(t, http.StatusBadGateway, errResp.StatusCode) + assert.Equal(t, http.StatusText(http.StatusBadGateway), errResp.Message) + }) + + // net/http only populates Response.Request for its own transport, and + // go-gh's HandleHTTPError dereferences it without checking. + t.Run("does not panic when the transport leaves Response.Request nil", func(t *testing.T) { + client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + header := http.Header{"Content-Type": []string{"application/json"}} + resp := stubResponse(req, http.StatusNotFound, io.NopCloser(strings.NewReader(`{"message":"Not Found"}`)), header) + resp.Request = nil + return resp + }) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "repos/OWNER/REPO", nil) + require.NoError(t, err) + + _, err = client.Do(req, nil) + + var errResp *ErrorResponse + require.ErrorAs(t, err, &errResp) + assert.Equal(t, "https://api.github.com/repos/OWNER/REPO", errResp.RequestURL.String()) + }) +} + +func TestWithCheckRedirect(t *testing.T) { + // A real redirect chain, so the policy is exercised by net/http rather than + // asserted against directly. + newRedirectServer := func(t *testing.T) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/legacy/asset", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/final/asset", http.StatusFound) + }) + mux.HandleFunc("/final/asset", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"asset"}`)) + }) + mux.HandleFunc("/rewritten/asset", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"name":"rewritten"}`)) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server + } + + t.Run("the policy intercepts a redirect and can rewrite the target", func(t *testing.T) { + server := newRedirectServer(t) + + var via int + client := newTestClient(t, server.URL, server.Client(), + WithCheckRedirect(func(req *http.Request, v []*http.Request) error { + via = len(v) + if len(v) == 1 { + req.URL.Path = "/rewritten/asset" + } + return nil + })) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "legacy/asset", nil) + require.NoError(t, err) + + var target struct { + Name string `json:"name"` + } + resp, err := client.Do(req, &target) + require.NoError(t, err) + + assert.Equal(t, 1, via, "the redirect policy must fire") + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "rewritten", target.Name, "the policy's rewrite must take effect") + }) + + // gh repo delete halts redirects this way, and then treats anything over + // 299 as an error. Send reports the halted 302 as an *ErrorResponse because + // isSuccess is 200-299, which is the same outcome by a different route. + t.Run("the policy can halt redirects, and a halted 302 is an ErrorResponse", func(t *testing.T) { + server := newRedirectServer(t) + + client := newTestClient(t, server.URL, server.Client(), + WithCheckRedirect(func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + })) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "legacy/asset", nil) + require.NoError(t, err) + + resp, err := client.Send(req) + require.Error(t, err) + assert.Nil(t, resp) + + var errResp *ErrorResponse + require.ErrorAs(t, err, &errResp) + assert.Equal(t, http.StatusFound, errResp.StatusCode) + assert.Equal(t, "/final/asset", errResp.Headers.Get("Location")) + }) + + t.Run("without the option redirects are followed normally", func(t *testing.T) { + server := newRedirectServer(t) + + client := newTestClient(t, server.URL, server.Client()) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "legacy/asset", nil) + require.NoError(t, err) + + var target struct { + Name string `json:"name"` + } + resp, err := client.Do(req, &target) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "asset", target.Name) + }) + + // NewClient stores the caller's *http.Client, and the CLI hands the same one + // to every command, so setting CheckRedirect in place would install this + // policy process-wide. + t.Run("does not mutate the caller's http.Client", func(t *testing.T) { + shared := &http.Client{} + + client := newTestClient(t, "https://api.github.com/", shared, + WithCheckRedirect(func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + })) + + assert.Nil(t, shared.CheckRedirect, "the caller's client must be left alone") + assert.NotNil(t, client.http.CheckRedirect) + assert.NotSame(t, shared, client.http) + }) + + t.Run("preserves the rest of the caller's http.Client", func(t *testing.T) { + transport := roundTripFunc(func(req *http.Request) (*http.Response, error) { + return stubResponse(req, http.StatusOK, io.NopCloser(strings.NewReader("{}")), nil), nil + }) + shared := &http.Client{Transport: transport, Timeout: 42 * time.Second} + + client := newTestClient(t, "https://api.github.com/", shared, + WithCheckRedirect(func(req *http.Request, via []*http.Request) error { + return nil + })) + + assert.Equal(t, 42*time.Second, client.http.Timeout) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil) + require.NoError(t, err) + + resp, err := client.Do(req, nil) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + t.Run("tolerates a nil http.Client", func(t *testing.T) { + client := newTestClient(t, "https://api.github.com/", nil, + WithCheckRedirect(func(req *http.Request, via []*http.Request) error { + return nil + })) + + require.NotNil(t, client.http) + assert.NotNil(t, client.http.CheckRedirect) + }) +} diff --git a/internal/githubv3/errors.go b/internal/githubv3/errors.go new file mode 100644 index 00000000000..980871fc9d4 --- /dev/null +++ b/internal/githubv3/errors.go @@ -0,0 +1,172 @@ +package githubv3 + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + ghAPI "github.com/cli/go-gh/v2/pkg/api" + ghauth "github.com/cli/go-gh/v2/pkg/auth" +) + +const ( + acceptedScopesHeader = "X-Accepted-Oauth-Scopes" + tokenScopesHeader = "X-Oauth-Scopes" +) + +// ErrorItem stores additional information about an error response returned +// from the GitHub REST API. It mirrors go-gh's HTTPErrorItem. +type ErrorItem struct { + Code string + Field string + Message string + Resource string +} + +// ErrorResponse is a non-2xx response from the GitHub REST API, destructured +// into its content. It is only ever constructed when the API successfully +// responded and reported failure in its own payload, so a transport-level +// failure such as a DNS error or a connection reset is never this type. +type ErrorResponse struct { + Errors []ErrorItem + Headers http.Header + Message string + RequestURL *url.URL + StatusCode int +} + +// Error allows ErrorResponse to satisfy the error interface. +func (e *ErrorResponse) Error() string { + if msgs := strings.SplitN(e.Message, "\n", 2); len(msgs) > 1 { + return fmt.Sprintf("HTTP %d: %s (%s)\n%s", e.StatusCode, msgs[0], e.RequestURL, msgs[1]) + } else if e.Message != "" { + return fmt.Sprintf("HTTP %d: %s (%s)", e.StatusCode, e.Message, e.RequestURL) + } + return fmt.Sprintf("HTTP %d (%s)", e.StatusCode, e.RequestURL) +} + +// ScopesSuggestion returns a suggestion to request additional OAuth scopes when +// the response indicates that the token is missing a scope the endpoint needs, +// and "" when there is nothing to suggest. +// +// It is computed from the fields of the error rather than stored, so a +// suggestion is always available when one exists. It is safe to call on a nil +// or zero-valued receiver, which matters because callers reach it holding a +// nil pointer after a failed errors.As. +func (e *ErrorResponse) ScopesSuggestion() string { + if e == nil { + return "" + } + + var hostname string + if e.RequestURL != nil { + hostname = e.RequestURL.Hostname() + } + + return generateScopesSuggestion( + e.StatusCode, + e.Headers.Get(acceptedScopesHeader), + e.Headers.Get(tokenScopesHeader), + hostname, + ) +} + +// NewErrorResponse parses a non-2xx response into an *ErrorResponse. +// +// It exists for callers that already hold a response and need the error for it, +// which is what api.HandleHTTPError does today. Send and Do build their own, so +// most callers never need this. +// +// It reads the response body but does not close it, matching go-gh's +// HandleHTTPError, which does the payload parsing. +// +// resp.Request may be nil, which happens with a custom RoundTripper because +// net/http only populates Response.Request for its own transport. go-gh's +// HandleHTTPError dereferences resp.Request.URL unguarded, so parsing runs +// against a shallow copy carrying a placeholder request rather than against the +// caller's response, which is left untouched. RequestURL then stays nil rather +// than becoming an empty *url.URL, because an empty URL satisfies every +// dereference and then lies in error messages. +func NewErrorResponse(resp *http.Response) *ErrorResponse { + errResp := &ErrorResponse{ + Headers: resp.Header, + StatusCode: resp.StatusCode, + } + + parseFrom := resp + if resp.Request == nil { + // http.Response holds no locks, so copying it is safe. + copied := *resp + copied.Request = &http.Request{} + parseFrom = &copied + } else { + errResp.RequestURL = resp.Request.URL + } + + // HandleHTTPError always returns a *ghAPI.HTTPError, but fall back rather + // than type assert so a change upstream cannot turn into a panic here. + ghErr, ok := ghAPI.HandleHTTPError(parseFrom).(*ghAPI.HTTPError) + if !ok { + errResp.Message = resp.Status + return errResp + } + + errResp.Message = ghErr.Message + for _, item := range ghErr.Errors { + errResp.Errors = append(errResp.Errors, ErrorItem(item)) + } + return errResp +} + +func generateScopesSuggestion(statusCode int, endpointNeedsScopes, tokenHasScopes, hostname string) string { + if statusCode < 400 || statusCode > 499 || statusCode == 422 { + return "" + } + + if tokenHasScopes == "" { + return "" + } + + gotScopes := map[string]struct{}{} + for _, s := range strings.Split(tokenHasScopes, ",") { + s = strings.TrimSpace(s) + gotScopes[s] = struct{}{} + + // Certain scopes may be grouped under a single "top-level" scope. The following branch + // statements include these grouped/implied scopes when the top-level scope is encountered. + // See https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps. + if s == "repo" { + gotScopes["repo:status"] = struct{}{} + gotScopes["repo_deployment"] = struct{}{} + gotScopes["public_repo"] = struct{}{} + gotScopes["repo:invite"] = struct{}{} + gotScopes["security_events"] = struct{}{} + } else if s == "user" { + gotScopes["read:user"] = struct{}{} + gotScopes["user:email"] = struct{}{} + gotScopes["user:follow"] = struct{}{} + } else if s == "codespace" { + gotScopes["codespace:secrets"] = struct{}{} + } else if strings.HasPrefix(s, "admin:") { + gotScopes["read:"+strings.TrimPrefix(s, "admin:")] = struct{}{} + gotScopes["write:"+strings.TrimPrefix(s, "admin:")] = struct{}{} + } else if strings.HasPrefix(s, "write:") { + gotScopes["read:"+strings.TrimPrefix(s, "write:")] = struct{}{} + } + } + + for _, s := range strings.Split(endpointNeedsScopes, ",") { + s = strings.TrimSpace(s) + if _, gotScope := gotScopes[s]; s == "" || gotScope { + continue + } + return fmt.Sprintf( + "This API operation needs the %[1]q scope. To request it, run: gh auth refresh -h %[2]s -s %[1]s", + s, + ghauth.NormalizeHostname(hostname), + ) + } + + return "" +} diff --git a/internal/githubv3/errors_test.go b/internal/githubv3/errors_test.go new file mode 100644 index 00000000000..df2183d24d8 --- /dev/null +++ b/internal/githubv3/errors_test.go @@ -0,0 +1,340 @@ +package githubv3 + +import ( + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestErrorResponseError(t *testing.T) { + requestURL, err := url.Parse("https://api.github.com/repos/OWNER/REPO") + require.NoError(t, err) + + tests := []struct { + name string + err *ErrorResponse + wantMsg string + }{ + { + name: "message", + err: &ErrorResponse{ + Message: "Not Found", + RequestURL: requestURL, + StatusCode: 404, + }, + wantMsg: "HTTP 404: Not Found (https://api.github.com/repos/OWNER/REPO)", + }, + { + name: "multi-line message", + err: &ErrorResponse{ + Message: "Validation Failed\nIssue.title is missing", + RequestURL: requestURL, + StatusCode: 422, + }, + wantMsg: "HTTP 422: Validation Failed (https://api.github.com/repos/OWNER/REPO)\nIssue.title is missing", + }, + { + name: "no message", + err: &ErrorResponse{ + RequestURL: requestURL, + StatusCode: 500, + }, + wantMsg: "HTTP 500 (https://api.github.com/repos/OWNER/REPO)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantMsg, tt.err.Error()) + }) + } +} + +// TestScopesSuggestionNilSafety pins the property a later migration step +// depends on: internal/ghcmd reaches ScopesSuggestion holding a nil pointer +// when errors.As returned false, and must not panic there. +func TestScopesSuggestionNilSafety(t *testing.T) { + t.Run("nil receiver", func(t *testing.T) { + var err *ErrorResponse + assert.NotPanics(t, func() { + assert.Equal(t, "", err.ScopesSuggestion()) + }) + }) + + t.Run("zero value", func(t *testing.T) { + err := &ErrorResponse{} + assert.NotPanics(t, func() { + assert.Equal(t, "", err.ScopesSuggestion()) + }) + }) + + t.Run("nil RequestURL with scopes present", func(t *testing.T) { + err := &ErrorResponse{ + StatusCode: 404, + Headers: http.Header{ + "X-Accepted-Oauth-Scopes": []string{"read:org"}, + "X-Oauth-Scopes": []string{"repo"}, + }, + } + assert.NotPanics(t, func() { + assert.Equal(t, + `This API operation needs the "read:org" scope. To request it, run: gh auth refresh -h -s read:org`, + err.ScopesSuggestion()) + }) + }) +} + +func TestScopesSuggestion(t *testing.T) { + tests := []struct { + name string + statusCode int + acceptedScopes string + tokenScopes string + requestURL string + wantSuggestion string + }{ + { + name: "no suggestion on 2xx", + statusCode: 200, + acceptedScopes: "repo, read:org", + tokenScopes: "repo", + requestURL: "https://api.github.com/gists", + }, + { + name: "no suggestion on 5xx", + statusCode: 500, + acceptedScopes: "repo, read:org", + tokenScopes: "repo", + requestURL: "https://api.github.com/gists", + }, + { + name: "no suggestion on 422", + statusCode: 422, + acceptedScopes: "repo, read:org", + tokenScopes: "repo", + requestURL: "https://api.github.com/gists", + }, + { + // The caller that renders this suggestion has a bug that hides it + // on a 401. That is recorded in the design spec and lives outside + // this package, so the behaviour here is unchanged. + name: "suggestion on 401", + statusCode: 401, + acceptedScopes: "repo, read:org", + tokenScopes: "repo", + requestURL: "https://api.github.com/gists", + wantSuggestion: `This API operation needs the "read:org" scope. To request it, run: gh auth refresh -h github.com -s read:org`, + }, + { + name: "suggestion on 403", + statusCode: 403, + acceptedScopes: "repo, read:org", + tokenScopes: "repo", + requestURL: "https://api.github.com/gists", + wantSuggestion: `This API operation needs the "read:org" scope. To request it, run: gh auth refresh -h github.com -s read:org`, + }, + { + name: "suggestion on 404", + statusCode: 404, + acceptedScopes: "repo, read:org", + tokenScopes: "repo", + requestURL: "https://api.github.com/gists", + wantSuggestion: `This API operation needs the "read:org" scope. To request it, run: gh auth refresh -h github.com -s read:org`, + }, + { + name: "no suggestion when the token has no scopes", + statusCode: 404, + acceptedScopes: "repo, read:org", + tokenScopes: "", + requestURL: "https://api.github.com/gists", + }, + { + name: "no suggestion when the endpoint needs no scopes", + statusCode: 404, + acceptedScopes: "", + tokenScopes: "repo", + requestURL: "https://api.github.com/gists", + }, + { + name: "no suggestion when the token already has the scope", + statusCode: 404, + acceptedScopes: "repo", + tokenScopes: "repo, read:org", + requestURL: "https://api.github.com/gists", + }, + { + name: "repo implies its grouped scopes", + statusCode: 404, + acceptedScopes: "public_repo", + tokenScopes: "repo", + requestURL: "https://api.github.com/gists", + }, + { + name: "user implies its grouped scopes", + statusCode: 404, + acceptedScopes: "read:user", + tokenScopes: "user", + requestURL: "https://api.github.com/gists", + }, + { + name: "codespace implies codespace:secrets", + statusCode: 404, + acceptedScopes: "codespace:secrets", + tokenScopes: "codespace", + requestURL: "https://api.github.com/gists", + }, + { + name: "admin implies read and write", + statusCode: 404, + acceptedScopes: "read:org, write:org", + tokenScopes: "admin:org", + requestURL: "https://api.github.com/gists", + }, + { + name: "write implies read", + statusCode: 404, + acceptedScopes: "read:org", + tokenScopes: "write:org", + requestURL: "https://api.github.com/gists", + }, + { + // The other cases already show api.github.com collapsing to + // github.com. An enterprise host has no such suffix and is used + // as it stands. + name: "an enterprise hostname is used as it stands", + statusCode: 404, + acceptedScopes: "read:org", + tokenScopes: "repo", + requestURL: "https://github.example.com/api/v3/gists", + wantSuggestion: `This API operation needs the "read:org" scope. To request it, run: gh auth refresh -h github.example.com -s read:org`, + }, + { + name: "a tenancy hostname is normalized to the tenant", + statusCode: 404, + acceptedScopes: "read:org", + tokenScopes: "repo", + requestURL: "https://api.tenant.ghe.com/gists", + wantSuggestion: `This API operation needs the "read:org" scope. To request it, run: gh auth refresh -h tenant.ghe.com -s read:org`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requestURL, err := url.Parse(tt.requestURL) + require.NoError(t, err) + + errResp := &ErrorResponse{ + Headers: http.Header{ + "X-Accepted-Oauth-Scopes": []string{tt.acceptedScopes}, + "X-Oauth-Scopes": []string{tt.tokenScopes}, + }, + RequestURL: requestURL, + StatusCode: tt.statusCode, + } + + assert.Equal(t, tt.wantSuggestion, errResp.ScopesSuggestion()) + }) + } +} + +// TestNewErrorResponse covers the exported parser, which exists for callers +// that already hold a response, such as api.HandleHTTPError. +func TestNewErrorResponse(t *testing.T) { + jsonHeader := func() http.Header { + return http.Header{"Content-Type": []string{"application/json"}} + } + + newResponse := func(status int, header http.Header, body string, req *http.Request) *http.Response { + return &http.Response{ + StatusCode: status, + Status: http.StatusText(status), + Header: header, + Body: io.NopCloser(strings.NewReader(body)), + Request: req, + } + } + + t.Run("parses the payload from a response the caller holds", func(t *testing.T) { + requestURL, err := url.Parse("https://api.github.com/repos/OWNER/REPO/issues") + require.NoError(t, err) + req := &http.Request{URL: requestURL} + + body := `{"message":"Validation Failed","errors":[{"resource":"Issue","field":"title","code":"missing_field"}]}` + resp := newResponse(http.StatusUnprocessableEntity, jsonHeader(), body, req) + + errResp := NewErrorResponse(resp) + + assert.Equal(t, http.StatusUnprocessableEntity, errResp.StatusCode) + assert.Equal(t, "Validation Failed\nIssue.title is missing", errResp.Message) + assert.Equal(t, []ErrorItem{{Resource: "Issue", Field: "title", Code: "missing_field"}}, errResp.Errors) + assert.Equal(t, requestURL, errResp.RequestURL) + assert.Equal(t, "application/json", errResp.Headers.Get("Content-Type")) + }) + + t.Run("falls back to the status for a non-JSON body", func(t *testing.T) { + requestURL, err := url.Parse("https://api.github.com/user") + require.NoError(t, err) + header := http.Header{"Content-Type": []string{"text/plain"}} + + resp := newResponse(http.StatusBadGateway, header, "oh no", &http.Request{URL: requestURL}) + + errResp := NewErrorResponse(resp) + + assert.Equal(t, http.StatusBadGateway, errResp.StatusCode) + assert.Equal(t, http.StatusText(http.StatusBadGateway), errResp.Message) + }) + + t.Run("does not close the body", func(t *testing.T) { + requestURL, err := url.Parse("https://api.github.com/user") + require.NoError(t, err) + + body := newTrackedBody(`{"message":"Not Found"}`) + resp := newResponse(http.StatusNotFound, jsonHeader(), "", &http.Request{URL: requestURL}) + resp.Body = body + + errResp := NewErrorResponse(resp) + + assert.Equal(t, "Not Found", errResp.Message) + assert.False(t, body.isClosed(), "closing the body is the caller's decision") + }) + + // go-gh's HandleHTTPError dereferences resp.Request.URL unguarded. This + // path is unreachable through httpmock, whose responders all set Request, + // so the response is built by hand rather than through a test double. + t.Run("tolerates a nil Request without mutating the caller's response", func(t *testing.T) { + resp := newResponse(http.StatusNotFound, jsonHeader(), `{"message":"Not Found"}`, nil) + + var errResp *ErrorResponse + require.NotPanics(t, func() { + errResp = NewErrorResponse(resp) + }) + + assert.Equal(t, http.StatusNotFound, errResp.StatusCode) + assert.Equal(t, "Not Found", errResp.Message) + assert.Nil(t, resp.Request, "the caller's response must be left as it was") + }) + + // An empty *url.URL would satisfy every dereference and then print as an + // empty string, so an error would read "HTTP 404: Not Found ()" as though a + // real URL were blank. A nil one prints as , which is visibly absent. + t.Run("leaves RequestURL nil rather than empty when Request is nil", func(t *testing.T) { + resp := newResponse(http.StatusNotFound, jsonHeader(), `{"message":"Not Found"}`, nil) + + errResp := NewErrorResponse(resp) + + assert.Nil(t, errResp.RequestURL) + assert.NotPanics(t, func() { + assert.Equal(t, "HTTP 404: Not Found ()", errResp.Error()) + }) + assert.Equal(t, "", errResp.ScopesSuggestion()) + + empty := &ErrorResponse{Message: "Not Found", RequestURL: &url.URL{}, StatusCode: 404} + assert.Equal(t, "HTTP 404: Not Found ()", empty.Error(), + "an empty URL would read as a real but blank URL, which is why nil is kept") + }) +} diff --git a/internal/githubv3/response.go b/internal/githubv3/response.go new file mode 100644 index 00000000000..72cbeea487e --- /dev/null +++ b/internal/githubv3/response.go @@ -0,0 +1,27 @@ +package githubv3 + +import ( + "net/http" + "regexp" +) + +var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) + +// Response is an HTTP response from the GitHub REST API. +type Response struct { + *http.Response +} + +// NextPage returns the rel="next" URL from the Link header, or "" if absent. +// +// It is the opaque URL the server supplied rather than a parsed page number, +// so cursor-based pagination keeps working. +func (r *Response) NextPage() string { + var next string + for _, m := range linkRE.FindAllStringSubmatch(r.Header.Get("Link"), -1) { + if len(m) > 2 && m[2] == "next" { + next = m[1] + } + } + return next +} diff --git a/internal/githubv3/response_test.go b/internal/githubv3/response_test.go new file mode 100644 index 00000000000..90ffd53476a --- /dev/null +++ b/internal/githubv3/response_test.go @@ -0,0 +1,65 @@ +package githubv3 + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNextPage(t *testing.T) { + tests := []struct { + name string + linkHeader string + wantNext string + }{ + { + name: "no Link header", + wantNext: "", + }, + { + name: "empty Link header", + linkHeader: "", + wantNext: "", + }, + { + name: "only a next relation", + linkHeader: `; rel="next"`, + wantNext: "https://api.github.com/repositories/1/issues?page=2", + }, + { + name: "multiple relations", + linkHeader: `; rel="next", ; rel="last"`, + wantNext: "https://api.github.com/repositories/1/issues?page=2", + }, + { + name: "next is not the first relation", + linkHeader: `; rel="prev", ; rel="first", ; rel="next"`, + wantNext: "https://api.github.com/repositories/1/issues?page=3", + }, + { + name: "no next relation", + linkHeader: `; rel="prev", ; rel="first"`, + wantNext: "", + }, + { + // Cursor pagination supplies an opaque URL rather than a page + // number, so the whole URL has to survive. + name: "cursor pagination", + linkHeader: `; rel="next"`, + wantNext: "https://api.github.com/organizations/1/members?per_page=100&after=Y3Vyc29yOnYyOpHOAAcqDA%3D%3D", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + header := http.Header{} + if tt.linkHeader != "" { + header.Set("Link", tt.linkHeader) + } + resp := &Response{Response: &http.Response{Header: header}} + + assert.Equal(t, tt.wantNext, resp.NextPage()) + }) + } +} From e3865877a0904ffb330978c6c9e9ecf969d63fdb Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 12:48:41 +0200 Subject: [PATCH 02/21] Rename githubv3 to githubrest and require explicit auth The API is versioned "v3" in its URL paths, but GitHub now versions the REST API by date through X-GitHub-Api-Version, so "v3" described the URL layout rather than the API a caller gets. Authentication becomes a required positional argument to NewClient rather than an option. It is the one decision that is wrong in both directions: a client that should authenticate but does not gets an unhelpful 404 for anything private, and one that should not but does sends a user's token somewhere it was never meant to go. Neither looks like a missing option at the call site, so the compiler asks instead. Absolute URLs are now checked against a set of hosts the client may send credentials to. The REST upload endpoint drove this: it is a separate host on github.com and a path on the same host on Enterprise Server, so a same-host rule would have passed on one and failed on the other. WithCredentialedHost keeps that deployment difference at construction, where the deployment is already known. NewUploadRequest bundles the parts an upload needs, since each fails quietly and elsewhere when missing: no length sends chunked encoding, which the endpoint rejects; no content type stores the wrong kind of file; and a body that cannot be replayed fails on any redirect. It takes an opener rather than a reader so GetBody is always set, which the existing release asset call site already provides. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 195575d5-99df-498d-bcfb-237299cac2d4 --- internal/githubrest/client.go | 400 ++++++++++++++++ .../{githubv3 => githubrest}/client_test.go | 437 +++++++++++++++--- internal/{githubv3 => githubrest}/errors.go | 2 +- .../{githubv3 => githubrest}/errors_test.go | 2 +- internal/{githubv3 => githubrest}/response.go | 2 +- .../{githubv3 => githubrest}/response_test.go | 2 +- internal/githubv3/client.go | 288 ------------ 7 files changed, 779 insertions(+), 354 deletions(-) create mode 100644 internal/githubrest/client.go rename internal/{githubv3 => githubrest}/client_test.go (59%) rename internal/{githubv3 => githubrest}/errors.go (99%) rename internal/{githubv3 => githubrest}/errors_test.go (99%) rename internal/{githubv3 => githubrest}/response.go (96%) rename internal/{githubv3 => githubrest}/response_test.go (99%) delete mode 100644 internal/githubv3/client.go diff --git a/internal/githubrest/client.go b/internal/githubrest/client.go new file mode 100644 index 00000000000..811a2dcb354 --- /dev/null +++ b/internal/githubrest/client.go @@ -0,0 +1,400 @@ +// Package githubrest is a client for GitHub's REST API. +// +// The API is versioned "v3" in its URL paths, but the name is avoided here: +// GitHub now versions the REST API by date through the X-GitHub-Api-Version +// header, so "v3" describes the URL layout rather than the API a caller gets. +// The name also collides confusingly with github.com/shurcooL/githubv4, which +// is a GraphQL query DSL rather than a REST client. +package githubrest + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + o "github.com/cli/cli/v2/pkg/option" +) + +const ( + authorizationHeader = "Authorization" + contentTypeHeader = "Content-Type" +) + +// Client sends requests to a single GitHub REST API host. +// +// It holds an already-resolved API base URL: it reads no configuration and has +// no opinion about how a canonical host such as github.example.com becomes an +// API host. That resolution happens once, where the client is constructed. +type Client struct { + apiBaseURL *url.URL + token o.Option[string] + http *http.Client + + // credentialedHosts are the hosts this client will send an Authorization + // header to, lowercased and including any port. + credentialedHosts map[string]struct{} +} + +// AuthStrategy states whether a client authenticates, and is a required +// argument to NewClient rather than an option. +// +// Authentication is the one decision that is wrong in both directions: a client +// that should authenticate but does not gets an unhelpful 404 for anything +// private, and a client that should not authenticate but does sends a user's +// token somewhere it was never meant to go. Neither failure looks like a +// missing option at the call site, so a caller is made to say which they want +// and the compiler asks the question. +type AuthStrategy func(*Client) + +// WithToken authenticates the client as exactly the given token. +// +// An empty token still sets the Authorization header, so this means "the token +// I hold, even if it is empty" rather than "no token". Callers that check one +// specific user's credentials, rather than the host's active ones, depend on +// that: they must send exactly what they hold and get whatever answer it earns. +func WithToken(token string) AuthStrategy { + return func(c *Client) { + c.token = o.Some(token) + } +} + +// WithoutToken declares that the client sets no Authorization header of its own. +// +// This states the client's intent, which is as far as its reach goes. It is not +// a guarantee of anonymity: an http.Client can carry a transport that adds +// credentials to any request lacking them, and this package neither knows nor +// controls what transport it was given. A caller that needs certainty about +// what is sent has to be sure of the transport too. +func WithoutToken() AuthStrategy { + return func(c *Client) { + c.token = o.None[string]() + } +} + +// ClientOption configures a Client at construction. +type ClientOption func(*Client) + +// WithCredentialedHost declares an additional host this client may send its +// token to. +// +// The API base URL's own host is always credentialed, so this is only needed +// for a second host in the same deployment. The REST upload endpoint is the +// case that exists: it lives at https://uploads.github.com/ on github.com but +// at https://HOST/api/uploads/ on Enterprise Server, so whether it is a +// different host is a deployment accident rather than anything a call site +// should have to reason about. Declaring it at construction, where the +// deployment is already known, keeps that difference out of call sites. +func WithCredentialedHost(host string) ClientOption { + return func(c *Client) { + c.credentialedHosts[strings.ToLower(host)] = struct{}{} + } +} + +// WithCheckRedirect sets the redirect policy for the client's requests. +// +// It is needed by callers that rewrite a redirect target, such as gh release +// download avoiding legacy Codeload paths, and by callers that halt redirects +// with http.ErrUseLastResponse, such as gh repo delete. Neither is expressible +// through a RequestOption, since a redirect policy belongs to the http.Client +// rather than to a request. +// +// The http.Client is shallow-copied and the policy set on the copy. This is not +// ceremony: NewClient stores the caller's *http.Client, and the CLI hands the +// same one to every command, so setting the field in place would install this +// policy process-wide for unrelated commands. Both call sites named above +// already hand-roll the same copy for the same reason. +func WithCheckRedirect(fn func(*http.Request, []*http.Request) error) ClientOption { + return func(c *Client) { + httpClient := &http.Client{} + if c.http != nil { + copied := *c.http + httpClient = &copied + } + httpClient.CheckRedirect = fn + c.http = httpClient + } +} + +// NewClient returns a Client that sends requests to apiBaseURL through +// httpClient, authenticating as auth describes. +// +// apiBaseURL is a resolved, absolute API base URL such as +// https://api.github.com/ or https://github.example.com/api/v3/, not a +// canonical host. It is validated here rather than per request, so a Client +// either resolves relative paths correctly or does not exist. +func NewClient(apiBaseURL string, httpClient *http.Client, auth AuthStrategy, opts ...ClientOption) (*Client, error) { + if apiBaseURL == "" { + return nil, errors.New("githubrest: apiBaseURL is required") + } + + parsed, err := url.Parse(apiBaseURL) + if err != nil { + return nil, fmt.Errorf("githubrest: parsing apiBaseURL: %w", err) + } + if parsed.Scheme == "" || parsed.Host == "" { + return nil, fmt.Errorf("githubrest: apiBaseURL %q is not absolute", apiBaseURL) + } + + // Callers cannot omit auth, but they can pass a nil of the right type, + // which would otherwise be a nil call below. + if auth == nil { + return nil, errors.New("githubrest: an auth strategy is required, so pass WithToken or WithoutToken") + } + + client := &Client{ + apiBaseURL: parsed, + http: httpClient, + credentialedHosts: map[string]struct{}{ + strings.ToLower(parsed.Host): {}, + }, + } + + auth(client) + + for _, opt := range opts { + opt(client) + } + + return client, nil +} + +// RequestOption modifies a request built by NewRequest. +// +// It receives the *http.Request itself, so it is not limited to single-valued +// headers, and a caller who needs something no option covers can mutate the +// request between NewRequest and Send. +type RequestOption func(*http.Request) + +// WithHeader sets a request header, replacing any existing values for name. +func WithHeader(name, value string) RequestOption { + return func(req *http.Request) { + req.Header.Set(name, value) + } +} + +// WithSingleRequestToken overrides the client's token for one request. +// +// Like WithToken, an empty token still sets the header. This exists for callers +// that hold one client but need a particular request to carry different +// credentials; a caller whose every request uses the same token should say so +// at construction instead. +func WithSingleRequestToken(token string) RequestOption { + return func(req *http.Request) { + req.Header.Set(authorizationHeader, authorizationValue(token)) + } +} + +// authorizationValue renders a token as an Authorization header value. +// +// An empty token deliberately yields a non-empty value, so that "authenticate +// as this empty token" is distinguishable from "no header was set". +func authorizationValue(token string) string { + return fmt.Sprintf("token %s", token) +} + +// NewRequest builds a request against the client's host. +// +// path may be a path relative to the client's API base URL, or an absolute URL +// on a credentialed host, which is what following a Link header pagination URL +// needs. +// +// The Authorization header is set from the client's token, when it has one, +// before opts are applied, so an option can override it. +// +// No other headers are set here. The CLI's transport applies User-Agent, +// X-GitHub-Api-Version and, unless they are skipped, Accept, Content-Type and +// Time-Zone at RoundTrip time, and skips any header already present on the +// request. An option therefore wins by being set earlier, not later, and +// setting defaults here would only risk double-setting them. +func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Reader, opts ...RequestOption) (*http.Request, error) { + resolved, err := c.resolveURL(path) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, method, resolved, body) + if err != nil { + return nil, err + } + + if token, ok := c.token.Value(); ok { + req.Header.Set(authorizationHeader, authorizationValue(token)) + } + + for _, opt := range opts { + opt(req) + } + + return req, nil +} + +// NewUploadRequest builds a request that uploads a file, such as a release +// asset. +// +// Every part of an upload is a required positional argument rather than an +// option, because an upload only works if all of it is applied, and each +// missing piece fails quietly and somewhere else: no length sends chunked +// encoding, which the upload endpoint rejects; no content type stores the asset +// as the wrong kind of file; and a body that cannot be replayed turns any +// redirect or retry into a failure after the request looked fine. Making these +// arguments means the compiler asks for them. +// +// open is called once for the body and kept as req.GetBody, so the request can +// be replayed. This is why it is a function rather than an io.Reader: a reader +// can be consumed only once. Callers that already model a file as something +// reopenable pass that directly. +// +// The returned request owns the reader that open produced, and sending the +// request closes it. A caller that builds a request and never sends it is +// responsible for closing the body itself. +// +// path is resolved and credentialed exactly as in NewRequest, so on github.com, +// where uploads live on their own host, the client needs WithCredentialedHost. +func (c *Client) NewUploadRequest(ctx context.Context, path string, open func() (io.ReadCloser, error), size int64, contentType string, opts ...RequestOption) (*http.Request, error) { + if open == nil { + return nil, errors.New("githubrest: open is required, because an upload body must be replayable") + } + if size < 0 { + return nil, fmt.Errorf("githubrest: size %d is negative", size) + } + if contentType == "" { + return nil, errors.New("githubrest: contentType is required, so pass application/octet-stream if nothing better is known") + } + + body, err := open() + if err != nil { + return nil, fmt.Errorf("githubrest: opening upload body: %w", err) + } + + req, err := c.NewRequest(ctx, http.MethodPost, path, body) + if err != nil { + body.Close() + return nil, err + } + + req.ContentLength = size + req.GetBody = open + req.Header.Set(contentTypeHeader, contentType) + + // Applied last, as in NewRequest, so an option can override any of these. + for _, opt := range opts { + opt(req) + } + + return req, nil +} + +// Send issues req and returns the response with its body still open, so the +// caller must close it. +// +// A non-2xx status yields a nil *Response and an *ErrorResponse. The body is +// closed on that path, because a caller holding no response has nothing to +// close. +func (c *Client) Send(req *http.Request) (*Response, error) { + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + + // net/http only populates Response.Request for its own transport, so a + // custom RoundTripper can leave it nil. Settling it here rather than in + // NewErrorResponse means the parser has one guarantee to reason about + // instead of two, and this response is ours to mutate. + if resp.Request == nil { + resp.Request = req + } + + if !isSuccess(resp.StatusCode) { + defer resp.Body.Close() + return nil, NewErrorResponse(resp) + } + + return &Response{Response: resp}, nil +} + +// Do issues req, decodes the JSON body into v, and closes the body. +// +// A nil v discards the body without decoding it. Statuses 204 and 205 skip +// decoding, since they carry no content. +// +// Once a response has been received successfully it is always returned, even +// alongside an error: the request succeeded and the status and headers are +// known good, so only body handling failed and the caller should keep them. +// The body of such a response is already closed, since Do closes it +// unconditionally, so only its metadata is readable. +// +// A non-2xx status yields a nil *Response and an *ErrorResponse. +func (c *Client) Do(req *http.Request, v any) (*Response, error) { + resp, err := c.Send(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusResetContent { + return resp, nil + } + + if v == nil { + // Drained rather than ignored so the connection can be reused. + _, err := io.Copy(io.Discard, resp.Body) + if err != nil { + return resp, err + } + return resp, nil + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return resp, err + } + + // An empty body reaches json.Unmarshal and fails with "unexpected end of + // JSON input". That is deliberate: it is what api.Client.REST and go-gh do + // today, so tolerating it the way google/go-github does would silently + // change behaviour at every existing call site. + if err := json.Unmarshal(b, v); err != nil { + return resp, err + } + + return resp, nil +} + +// resolveURL joins path to the client's API base URL, or checks an absolute URL +// against the hosts this client may send credentials to. +// +// The join is textual rather than url.URL.JoinPath, which percent-escapes the +// query separator and so would turn "issues?state=open" into +// "issues%3Fstate=open", silently breaking every filtered and paginated call +// site. go-gh concatenates for the same reason. +func (c *Client) resolveURL(path string) (string, error) { + if !strings.HasPrefix(path, "https://") && !strings.HasPrefix(path, "http://") { + return strings.TrimSuffix(c.apiBaseURL.String(), "/") + "/" + strings.TrimPrefix(path, "/"), nil + } + + parsed, err := url.Parse(path) + if err != nil { + return "", fmt.Errorf("githubrest: parsing path %q: %w", path, err) + } + + if _, ok := c.credentialedHosts[strings.ToLower(parsed.Host)]; !ok { + return "", fmt.Errorf("githubrest: refusing to send credentials to %q, so if this host belongs to the same deployment declare it with WithCredentialedHost", parsed.Host) + } + + // A credentialed host reached over plaintext would put the token on the + // wire, so it is refused even though the host itself is trusted. + if c.apiBaseURL.Scheme == "https" && parsed.Scheme != "https" { + return "", fmt.Errorf("githubrest: refusing to request %q over http when the client uses https", path) + } + + return path, nil +} + +func isSuccess(statusCode int) bool { + return statusCode >= 200 && statusCode < 300 +} diff --git a/internal/githubv3/client_test.go b/internal/githubrest/client_test.go similarity index 59% rename from internal/githubv3/client_test.go rename to internal/githubrest/client_test.go index f552650f5cf..12ef95494bb 100644 --- a/internal/githubv3/client_test.go +++ b/internal/githubrest/client_test.go @@ -1,4 +1,4 @@ -package githubv3 +package githubrest import ( "context" @@ -80,7 +80,7 @@ func stubResponse(req *http.Request, status int, body io.ReadCloser, header http // stubClient returns a client whose transport always replies with resp, along // with a pointer that captures the request that was sent. -func stubClient(t *testing.T, baseURL, token string, respFn func(*http.Request) *http.Response) (*Client, **http.Request) { +func stubClient(t *testing.T, baseURL string, auth AuthStrategy, respFn func(*http.Request) *http.Response) (*Client, **http.Request) { t.Helper() var sent *http.Request @@ -89,18 +89,18 @@ func stubClient(t *testing.T, baseURL, token string, respFn func(*http.Request) return respFn(req), nil })} - client, err := NewClient(baseURL, httpClient, WithClientToken(token)) + client, err := NewClient(baseURL, httpClient, auth) require.NoError(t, err) return client, &sent } -// newTestClient builds a client for the common case, failing the test if the -// base URL is not usable. -func newTestClient(t *testing.T, baseURL string, httpClient *http.Client, opts ...ClientOption) *Client { +// newTestClient mirrors NewClient, taking the auth strategy explicitly so that +// tests state it as production code has to. +func newTestClient(t *testing.T, baseURL string, httpClient *http.Client, auth AuthStrategy, opts ...ClientOption) *Client { t.Helper() - client, err := NewClient(baseURL, httpClient, opts...) + client, err := NewClient(baseURL, httpClient, auth, opts...) require.NoError(t, err) return client } @@ -143,22 +143,28 @@ func TestNewRequestURL(t *testing.T) { wantURL: "https://api.github.com/repos/OWNER/REPO/issues?state=open&per_page=100", }, { - name: "absolute https URL ignores the base URL", + name: "absolute URL on the same host is passed through, as Link headers supply", baseURL: "https://api.github.com/", - path: "https://uploads.github.com/repos/OWNER/REPO/releases/1/assets?name=x", - wantURL: "https://uploads.github.com/repos/OWNER/REPO/releases/1/assets?name=x", + path: "https://api.github.com/repositories/1/issues?page=2", + wantURL: "https://api.github.com/repositories/1/issues?page=2", }, { - name: "absolute http URL ignores the base URL", + name: "absolute URL on the same host is compared case insensitively", baseURL: "https://api.github.com/", - path: "http://api.github.localhost/repos/OWNER/REPO", - wantURL: "http://api.github.localhost/repos/OWNER/REPO", + path: "https://API.GitHub.com/repos/OWNER/REPO", + wantURL: "https://API.GitHub.com/repos/OWNER/REPO", + }, + { + name: "absolute URL on the enterprise upload path, which shares the host", + baseURL: "https://github.example.com/api/v3/", + path: "https://github.example.com/api/uploads/repos/OWNER/REPO/releases/1/assets", + wantURL: "https://github.example.com/api/uploads/repos/OWNER/REPO/releases/1/assets", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client := newTestClient(t, tt.baseURL, nil) + client := newTestClient(t, tt.baseURL, nil, WithoutToken()) req, err := client.NewRequest(context.Background(), http.MethodGet, tt.path, nil) require.NoError(t, err) @@ -168,53 +174,173 @@ func TestNewRequestURL(t *testing.T) { } } +// A cross-host absolute URL would send the client's token to a host the caller +// never configured, and such URLs typically come from a response rather than +// from the caller, so they are rejected rather than followed. +func TestNewRequestRejectsCrossHostAbsoluteURLs(t *testing.T) { + tests := []struct { + name string + baseURL string + path string + wantErr string + }{ + { + name: "a different host", + baseURL: "https://api.github.com/", + path: "https://uploads.github.com/repos/OWNER/REPO/releases/1/assets?name=x", + wantErr: "declare it with WithCredentialedHost", + }, + { + name: "an unrelated host", + baseURL: "https://api.github.com/", + path: "https://evil.example.com/repos/OWNER/REPO", + wantErr: "declare it with WithCredentialedHost", + }, + { + name: "a credentialed host over plaintext, which would expose the token", + baseURL: "https://api.github.com/", + path: "http://api.github.com/repos/OWNER/REPO", + wantErr: "over http when the client uses https", + }, + { + name: "the same hostname on another port", + baseURL: "https://github.example.com/api/v3/", + path: "https://github.example.com:8443/api/v3/repos/OWNER/REPO", + wantErr: "declare it with WithCredentialedHost", + }, + { + name: "an enterprise host when the client is configured for github.com", + baseURL: "https://api.github.com/", + path: "https://github.example.com/api/v3/repos/OWNER/REPO", + wantErr: "declare it with WithCredentialedHost", + }, + { + name: "an absolute URL that cannot be parsed, so its host is unknowable", + baseURL: "https://api.github.com/", + path: "https://api.github.com:notaport/repos/OWNER/REPO", + wantErr: "parsing path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := newTestClient(t, tt.baseURL, nil, WithToken("SECRET")) + + req, err := client.NewRequest(context.Background(), http.MethodGet, tt.path, nil) + + require.Error(t, err) + assert.Nil(t, req, "no request should be built, so the token cannot be sent") + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +// The REST upload endpoint is a separate host on github.com and a path on the +// same host on Enterprise Server. WithCredentialedHost exists so that one call +// site works on both, with the difference confined to construction. +func TestWithCredentialedHost(t *testing.T) { + const uploadPath = "releases/1/assets?name=example.zip" + + t.Run("github.com reaches the upload host once it is declared", func(t *testing.T) { + client := newTestClient(t, "https://api.github.com/", nil, + WithToken("SECRET"), + WithCredentialedHost("uploads.github.com"), + ) + + req, err := client.NewRequest(context.Background(), http.MethodPost, "https://uploads.github.com/repos/OWNER/REPO/"+uploadPath, nil) + require.NoError(t, err) + + assert.Equal(t, "https://uploads.github.com/repos/OWNER/REPO/"+uploadPath, req.URL.String()) + assert.Equal(t, "token SECRET", req.Header.Get("Authorization")) + }) + + t.Run("enterprise reaches the upload path with no declaration needed", func(t *testing.T) { + client := newTestClient(t, "https://github.example.com/api/v3/", nil, WithToken("SECRET")) + + req, err := client.NewRequest(context.Background(), http.MethodPost, "https://github.example.com/api/uploads/repos/OWNER/REPO/"+uploadPath, nil) + require.NoError(t, err) + + assert.Equal(t, "https://github.example.com/api/uploads/repos/OWNER/REPO/"+uploadPath, req.URL.String()) + assert.Equal(t, "token SECRET", req.Header.Get("Authorization")) + }) + + t.Run("a declared host is matched case insensitively", func(t *testing.T) { + client := newTestClient(t, "https://api.github.com/", nil, WithoutToken(), WithCredentialedHost("Uploads.GitHub.com")) + + req, err := client.NewRequest(context.Background(), http.MethodPost, "https://uploads.github.com/repos/OWNER/REPO/"+uploadPath, nil) + require.NoError(t, err) + + assert.Equal(t, "https://uploads.github.com/repos/OWNER/REPO/"+uploadPath, req.URL.String()) + }) + + t.Run("declaring one host does not credential another", func(t *testing.T) { + client := newTestClient(t, "https://api.github.com/", nil, WithoutToken(), WithCredentialedHost("uploads.github.com")) + + _, err := client.NewRequest(context.Background(), http.MethodGet, "https://evil.example.com/repos/OWNER/REPO", nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "refusing to send credentials") + }) + + t.Run("a declared host cannot be reached over plaintext", func(t *testing.T) { + client := newTestClient(t, "https://api.github.com/", nil, WithoutToken(), WithCredentialedHost("uploads.github.com")) + + _, err := client.NewRequest(context.Background(), http.MethodPost, "http://uploads.github.com/repos/OWNER/REPO/"+uploadPath, nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "over http when the client uses https") + }) +} + func TestNewRequestAuthorization(t *testing.T) { tests := []struct { name string - clientOpts []ClientOption + auth AuthStrategy opts []RequestOption wantAuth string wantAuthSet bool }{ { - name: "a client with no token leaves Authorization unset for the transport", + name: "WithoutToken leaves Authorization unset", + auth: WithoutToken(), wantAuthSet: false, }, { - name: "WithClientToken sets Authorization", - clientOpts: []ClientOption{WithClientToken("CLIENT-TOKEN")}, + name: "WithToken sets Authorization", + auth: WithToken("CLIENT-TOKEN"), wantAuth: "token CLIENT-TOKEN", wantAuthSet: true, }, { - name: "WithClientToken with an empty token still sets Authorization", - clientOpts: []ClientOption{WithClientToken("")}, + name: "WithToken with an empty token still sets Authorization", + auth: WithToken(""), wantAuth: "token ", wantAuthSet: true, }, { - name: "WithToken overrides the client token", - clientOpts: []ClientOption{WithClientToken("CLIENT-TOKEN")}, - opts: []RequestOption{WithToken("OTHER-TOKEN")}, + name: "WithSingleRequestToken overrides the client token", + auth: WithToken("CLIENT-TOKEN"), + opts: []RequestOption{WithSingleRequestToken("OTHER-TOKEN")}, wantAuth: "token OTHER-TOKEN", wantAuthSet: true, }, { - name: "WithToken sets Authorization on a client with no token", - opts: []RequestOption{WithToken("OTHER-TOKEN")}, + name: "WithSingleRequestToken supplies a token the client does not set itself", + auth: WithoutToken(), + opts: []RequestOption{WithSingleRequestToken("OTHER-TOKEN")}, wantAuth: "token OTHER-TOKEN", wantAuthSet: true, }, { - name: "WithToken with an empty token still sets Authorization", - clientOpts: []ClientOption{WithClientToken("CLIENT-TOKEN")}, - opts: []RequestOption{WithToken("")}, + name: "WithSingleRequestToken with an empty token still sets Authorization", + auth: WithToken("CLIENT-TOKEN"), + opts: []RequestOption{WithSingleRequestToken("")}, wantAuth: "token ", wantAuthSet: true, }, { name: "WithHeader can override Authorization", - clientOpts: []ClientOption{WithClientToken("CLIENT-TOKEN")}, + auth: WithToken("CLIENT-TOKEN"), opts: []RequestOption{WithHeader("Authorization", "SET-BY-HEADER")}, wantAuth: "SET-BY-HEADER", wantAuthSet: true, @@ -223,7 +349,7 @@ func TestNewRequestAuthorization(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client := newTestClient(t, "https://api.github.com/", nil, tt.clientOpts...) + client := newTestClient(t, "https://api.github.com/", nil, tt.auth) req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil, tt.opts...) require.NoError(t, err) @@ -244,23 +370,24 @@ func TestNewRequestAuthorization(t *testing.T) { // credentials. func TestEmptyTokenStandsDownTheTransport(t *testing.T) { tests := []struct { - name string - client []ClientOption - req []RequestOption + name string + auth AuthStrategy + req []RequestOption }{ { - name: "client token", - client: []ClientOption{WithClientToken("")}, + name: "client token", + auth: WithToken(""), }, { name: "request token", - req: []RequestOption{WithToken("")}, + auth: WithoutToken(), + req: []RequestOption{WithSingleRequestToken("")}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client := newTestClient(t, "https://api.github.com/", nil, tt.client...) + client := newTestClient(t, "https://api.github.com/", nil, tt.auth) req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil, tt.req...) require.NoError(t, err) @@ -273,29 +400,50 @@ func TestEmptyTokenStandsDownTheTransport(t *testing.T) { func TestNewClient(t *testing.T) { t.Run("rejects an empty base URL", func(t *testing.T) { - _, err := NewClient("", nil) + _, err := NewClient("", nil, WithoutToken()) require.Error(t, err) }) t.Run("rejects a relative base URL", func(t *testing.T) { - _, err := NewClient("api.github.com", nil) + _, err := NewClient("api.github.com", nil, WithoutToken()) require.Error(t, err) }) t.Run("rejects an unparseable base URL", func(t *testing.T) { - _, err := NewClient("https://api.github.com/\x7f", nil) + _, err := NewClient("https://api.github.com/\x7f", nil, WithoutToken()) require.Error(t, err) }) t.Run("accepts an absolute base URL", func(t *testing.T) { - client, err := NewClient("https://api.github.com/", nil) + client, err := NewClient("https://api.github.com/", nil, WithoutToken()) require.NoError(t, err) assert.NotNil(t, client) }) + + // Omitting auth entirely is a compile error, so the only way to reach this + // is to pass an explicit nil, which would otherwise panic in NewClient. + t.Run("rejects a nil auth strategy", func(t *testing.T) { + _, err := NewClient("https://api.github.com/", nil, nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "WithToken") + assert.Contains(t, err.Error(), "WithoutToken") + }) + + t.Run("accepts a client that declares it sets no token", func(t *testing.T) { + client, err := NewClient("https://api.github.com/", nil, WithoutToken()) + require.NoError(t, err) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "repos/OWNER/REPO", nil) + require.NoError(t, err) + + _, ok := req.Header["Authorization"] + assert.False(t, ok, "no Authorization header should be set") + }) } func TestNewRequestOptions(t *testing.T) { - client := newTestClient(t, "https://api.github.com/", nil) + client := newTestClient(t, "https://api.github.com/", nil, WithoutToken()) t.Run("WithHeader sets a header", func(t *testing.T) { req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil, @@ -358,7 +506,7 @@ func TestRequestOptionSurvivesGoGHDefaultHeaders(t *testing.T) { }) require.NoError(t, err) - client := newTestClient(t, "https://api.github.com/", httpClient, WithClientToken("CLIENT-TOKEN")) + client := newTestClient(t, "https://api.github.com/", httpClient, WithToken("CLIENT-TOKEN")) req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil, WithHeader("Accept", "application/vnd.github.v3.diff")) @@ -376,7 +524,7 @@ func TestRequestOptionSurvivesGoGHDefaultHeaders(t *testing.T) { func TestSend(t *testing.T) { t.Run("leaves the body open for the caller", func(t *testing.T) { body := newTrackedBody(`{"name":"REPO"}`) - client, sent := stubClient(t, "https://api.github.com/", "TOKEN", func(req *http.Request) *http.Response { + client, sent := stubClient(t, "https://api.github.com/", WithToken("TOKEN"), func(req *http.Request) *http.Response { return stubResponse(req, http.StatusOK, body, nil) }) @@ -398,7 +546,7 @@ func TestSend(t *testing.T) { }) t.Run("exposes the success status code", func(t *testing.T) { - client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { return stubResponse(req, http.StatusCreated, io.NopCloser(strings.NewReader("{}")), nil) }) @@ -414,7 +562,7 @@ func TestSend(t *testing.T) { t.Run("closes the body on the error path", func(t *testing.T) { body := newTrackedBody(`{"message":"Not Found"}`) - client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { header := http.Header{"Content-Type": []string{"application/json"}} return stubResponse(req, http.StatusNotFound, body, header) }) @@ -433,7 +581,7 @@ func TestSend(t *testing.T) { httpClient := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { return nil, wantErr })} - client := newTestClient(t, "https://api.github.com/", httpClient) + client := newTestClient(t, "https://api.github.com/", httpClient, WithoutToken()) req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil) require.NoError(t, err) @@ -450,7 +598,7 @@ func TestSend(t *testing.T) { func TestDo(t *testing.T) { t.Run("decodes the body and closes it", func(t *testing.T) { body := newTrackedBody(`{"name":"REPO"}`) - client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { return stubResponse(req, http.StatusOK, body, nil) }) @@ -470,7 +618,7 @@ func TestDo(t *testing.T) { t.Run("a nil v discards the body without decoding", func(t *testing.T) { body := newTrackedBody("this is not JSON") - client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { return stubResponse(req, http.StatusOK, body, nil) }) @@ -485,7 +633,7 @@ func TestDo(t *testing.T) { }) t.Run("a malformed body is an error that still returns the response", func(t *testing.T) { - client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { return stubResponse(req, http.StatusOK, io.NopCloser(strings.NewReader("not JSON")), nil) }) @@ -527,7 +675,7 @@ func TestDo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { return stubResponse(req, http.StatusCreated, tt.body(), nil) }) @@ -546,7 +694,7 @@ func TestDo(t *testing.T) { // google/go-github swallows it instead, and matching that would silently // change behaviour at every existing call site. t.Run("an empty body with a non-nil v is an error", func(t *testing.T) { - client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { return stubResponse(req, http.StatusOK, io.NopCloser(strings.NewReader("")), nil) }) @@ -571,7 +719,7 @@ func TestDo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body := newTrackedBody("this is not JSON") - client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { return stubResponse(req, tt.status, body, nil) }) @@ -589,7 +737,7 @@ func TestDo(t *testing.T) { }) t.Run("a non-2xx status yields an ErrorResponse", func(t *testing.T) { - client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { header := http.Header{"Content-Type": []string{"application/json"}} body := io.NopCloser(strings.NewReader(`{ "message": "Validation Failed", @@ -616,7 +764,7 @@ func TestDo(t *testing.T) { }) t.Run("a non-JSON error body falls back to the status", func(t *testing.T) { - client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { header := http.Header{"Content-Type": []string{"text/plain"}} return stubResponse(req, http.StatusBadGateway, io.NopCloser(strings.NewReader("oh no")), header) }) @@ -635,7 +783,7 @@ func TestDo(t *testing.T) { // net/http only populates Response.Request for its own transport, and // go-gh's HandleHTTPError dereferences it without checking. t.Run("does not panic when the transport leaves Response.Request nil", func(t *testing.T) { - client, _ := stubClient(t, "https://api.github.com/", "", func(req *http.Request) *http.Response { + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { header := http.Header{"Content-Type": []string{"application/json"}} resp := stubResponse(req, http.StatusNotFound, io.NopCloser(strings.NewReader(`{"message":"Not Found"}`)), header) resp.Request = nil @@ -681,7 +829,7 @@ func TestWithCheckRedirect(t *testing.T) { server := newRedirectServer(t) var via int - client := newTestClient(t, server.URL, server.Client(), + client := newTestClient(t, server.URL, server.Client(), WithoutToken(), WithCheckRedirect(func(req *http.Request, v []*http.Request) error { via = len(v) if len(v) == 1 { @@ -710,7 +858,7 @@ func TestWithCheckRedirect(t *testing.T) { t.Run("the policy can halt redirects, and a halted 302 is an ErrorResponse", func(t *testing.T) { server := newRedirectServer(t) - client := newTestClient(t, server.URL, server.Client(), + client := newTestClient(t, server.URL, server.Client(), WithoutToken(), WithCheckRedirect(func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse })) @@ -731,7 +879,7 @@ func TestWithCheckRedirect(t *testing.T) { t.Run("without the option redirects are followed normally", func(t *testing.T) { server := newRedirectServer(t) - client := newTestClient(t, server.URL, server.Client()) + client := newTestClient(t, server.URL, server.Client(), WithoutToken()) req, err := client.NewRequest(context.Background(), http.MethodGet, "legacy/asset", nil) require.NoError(t, err) @@ -752,7 +900,7 @@ func TestWithCheckRedirect(t *testing.T) { t.Run("does not mutate the caller's http.Client", func(t *testing.T) { shared := &http.Client{} - client := newTestClient(t, "https://api.github.com/", shared, + client := newTestClient(t, "https://api.github.com/", shared, WithoutToken(), WithCheckRedirect(func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse })) @@ -768,7 +916,7 @@ func TestWithCheckRedirect(t *testing.T) { }) shared := &http.Client{Transport: transport, Timeout: 42 * time.Second} - client := newTestClient(t, "https://api.github.com/", shared, + client := newTestClient(t, "https://api.github.com/", shared, WithoutToken(), WithCheckRedirect(func(req *http.Request, via []*http.Request) error { return nil })) @@ -784,7 +932,7 @@ func TestWithCheckRedirect(t *testing.T) { }) t.Run("tolerates a nil http.Client", func(t *testing.T) { - client := newTestClient(t, "https://api.github.com/", nil, + client := newTestClient(t, "https://api.github.com/", nil, WithoutToken(), WithCheckRedirect(func(req *http.Request, via []*http.Request) error { return nil })) @@ -793,3 +941,168 @@ func TestWithCheckRedirect(t *testing.T) { assert.NotNil(t, client.http.CheckRedirect) }) } + +// openerFor returns an opener over content, counting how many times it is +// called, which is how a replayed body shows up. +func openerFor(content string, calls *int) func() (io.ReadCloser, error) { + return func() (io.ReadCloser, error) { + *calls++ + return io.NopCloser(strings.NewReader(content)), nil + } +} + +func TestNewUploadRequest(t *testing.T) { + ctx := context.Background() + const uploadURL = "https://uploads.github.com/repos/OWNER/REPO/releases/1/assets?name=example.zip" + + uploadClient := func(t *testing.T) *Client { + t.Helper() + return newTestClient(t, "https://api.github.com/", nil, + WithToken("SECRET"), + WithCredentialedHost("uploads.github.com"), + ) + } + + t.Run("sets everything an upload needs", func(t *testing.T) { + calls := 0 + client := uploadClient(t) + + req, err := client.NewUploadRequest(ctx, uploadURL, openerFor("asset contents", &calls), 14, "application/zip") + require.NoError(t, err) + + assert.Equal(t, http.MethodPost, req.Method) + assert.Equal(t, uploadURL, req.URL.String()) + assert.Equal(t, int64(14), req.ContentLength, "a missing length would send chunked encoding") + assert.Equal(t, "application/zip", req.Header.Get("Content-Type")) + assert.Equal(t, "token SECRET", req.Header.Get("Authorization")) + require.NotNil(t, req.GetBody, "a missing GetBody would fail to replay on a redirect") + }) + + t.Run("GetBody reopens the body rather than replaying a consumed reader", func(t *testing.T) { + calls := 0 + client := uploadClient(t) + + req, err := client.NewUploadRequest(ctx, uploadURL, openerFor("asset contents", &calls), 14, "application/zip") + require.NoError(t, err) + + first, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, "asset contents", string(first)) + + replay, err := req.GetBody() + require.NoError(t, err) + second, err := io.ReadAll(replay) + require.NoError(t, err) + + assert.Equal(t, "asset contents", string(second), "the replayed body should be whole, not empty") + assert.Equal(t, 2, calls, "GetBody should open the file again") + }) + + t.Run("options are applied after the upload headers, so they can override them", func(t *testing.T) { + calls := 0 + client := uploadClient(t) + + req, err := client.NewUploadRequest(ctx, uploadURL, openerFor("x", &calls), 1, "application/zip", + WithHeader("Content-Type", "application/octet-stream"), + ) + require.NoError(t, err) + + assert.Equal(t, "application/octet-stream", req.Header.Get("Content-Type")) + }) + + // Nothing takes ownership of the reader when the request is never built, so + // the opened file has to be closed here or it leaks. + t.Run("the upload host must be credentialed like any other, and the opened body is closed", func(t *testing.T) { + client := newTestClient(t, "https://api.github.com/", nil, WithToken("SECRET")) + body := &recordingCloser{Reader: strings.NewReader("x")} + + _, err := client.NewUploadRequest(ctx, uploadURL, func() (io.ReadCloser, error) { + return body, nil + }, 1, "application/zip") + + require.Error(t, err) + assert.Contains(t, err.Error(), "refusing to send credentials") + assert.True(t, body.closed, "the body opened before the failure should be closed") + }) + + t.Run("a body that cannot be opened is an error", func(t *testing.T) { + client := uploadClient(t) + + _, err := client.NewUploadRequest(ctx, uploadURL, func() (io.ReadCloser, error) { + return nil, errors.New("permission denied") + }, 1, "application/zip") + + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") + }) +} + +func TestNewUploadRequestRequiresEveryPart(t *testing.T) { + ctx := context.Background() + const uploadURL = "https://api.github.com/repos/OWNER/REPO/releases/1/assets?name=x" + + okOpen := func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("x")), nil } + + tests := []struct { + name string + open func() (io.ReadCloser, error) + size int64 + contentType string + wantErr string + }{ + { + name: "no opener", + size: 1, + contentType: "application/zip", + wantErr: "open is required", + }, + { + name: "negative size", + open: okOpen, + size: -1, + contentType: "application/zip", + wantErr: "is negative", + }, + { + name: "no content type", + open: okOpen, + size: 1, + wantErr: "contentType is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := newTestClient(t, "https://api.github.com/", nil, WithToken("SECRET")) + + _, err := client.NewUploadRequest(ctx, uploadURL, tt.open, tt.size, tt.contentType) + + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +// An empty file is a legitimate asset, so zero length must not be confused with +// a missing length. +func TestNewUploadRequestAllowsAnEmptyFile(t *testing.T) { + calls := 0 + client := newTestClient(t, "https://api.github.com/", nil, WithToken("SECRET")) + + req, err := client.NewUploadRequest(context.Background(), + "https://api.github.com/repos/OWNER/REPO/releases/1/assets?name=empty", + openerFor("", &calls), 0, "application/octet-stream") + require.NoError(t, err) + + assert.Equal(t, int64(0), req.ContentLength) +} + +type recordingCloser struct { + io.Reader + closed bool +} + +func (r *recordingCloser) Close() error { + r.closed = true + return nil +} diff --git a/internal/githubv3/errors.go b/internal/githubrest/errors.go similarity index 99% rename from internal/githubv3/errors.go rename to internal/githubrest/errors.go index 980871fc9d4..a95536581dd 100644 --- a/internal/githubv3/errors.go +++ b/internal/githubrest/errors.go @@ -1,4 +1,4 @@ -package githubv3 +package githubrest import ( "fmt" diff --git a/internal/githubv3/errors_test.go b/internal/githubrest/errors_test.go similarity index 99% rename from internal/githubv3/errors_test.go rename to internal/githubrest/errors_test.go index df2183d24d8..76ad1cc41c0 100644 --- a/internal/githubv3/errors_test.go +++ b/internal/githubrest/errors_test.go @@ -1,4 +1,4 @@ -package githubv3 +package githubrest import ( "io" diff --git a/internal/githubv3/response.go b/internal/githubrest/response.go similarity index 96% rename from internal/githubv3/response.go rename to internal/githubrest/response.go index 72cbeea487e..8823cbdc46c 100644 --- a/internal/githubv3/response.go +++ b/internal/githubrest/response.go @@ -1,4 +1,4 @@ -package githubv3 +package githubrest import ( "net/http" diff --git a/internal/githubv3/response_test.go b/internal/githubrest/response_test.go similarity index 99% rename from internal/githubv3/response_test.go rename to internal/githubrest/response_test.go index 90ffd53476a..1f2b6831313 100644 --- a/internal/githubv3/response_test.go +++ b/internal/githubrest/response_test.go @@ -1,4 +1,4 @@ -package githubv3 +package githubrest import ( "net/http" diff --git a/internal/githubv3/client.go b/internal/githubv3/client.go deleted file mode 100644 index ce0bc1eda58..00000000000 --- a/internal/githubv3/client.go +++ /dev/null @@ -1,288 +0,0 @@ -// Package githubv3 is a client for GitHub's REST (v3) API. It is unrelated to -// github.com/shurcooL/githubv4, which is a GraphQL query DSL rather than a client. -package githubv3 - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" -) - -const authorizationHeader = "Authorization" - -// Client sends requests to a single GitHub REST API host. -// -// It holds an already-resolved API base URL: it reads no configuration and has -// no opinion about how a canonical host such as github.example.com becomes an -// API host. That resolution happens once, where the client is constructed. -// -// A token is optional, and its absence means "let the transport supply -// credentials", never "send an unauthenticated request". See WithClientToken. -type Client struct { - apiBaseURL *url.URL - token *string - http *http.Client -} - -// ClientOption configures a Client at construction. -type ClientOption func(*Client) - -// WithClientToken sets the token the client authenticates with, and is the only -// way to give a client a token. -// -// It always sets the Authorization header, including when t is empty, so the -// client authenticates as exactly the given token and nothing else. Omitting -// this option does not mean "unauthenticated": it means the header is left -// unset, and whatever credentials the transport supplies are used instead. The -// package deliberately offers no way to express "send no credentials", because -// no caller needs it and the transport would override it anyway. -// -// Callers such as gh auth status, which check one specific user's token rather -// than the host's active one, must pass this option even when the token they -// hold is empty. Without it, api.AddAuthTokenHeader steps in whenever -// Authorization is unset and authenticates as the *active* user, so the command -// would report on the wrong credentials. That is why an empty t still sets the -// header: the value is rendered as "token ", which is non-empty, and both -// AddAuthTokenHeader and go-gh's header round tripper stand down only when -// Authorization is non-empty. A header set to "" would not stand them down. -// -// Forgetting this option is therefore a silent wrong-answer hazard rather than -// an error. Containing it here is deliberate; fixing the transport so that it -// cannot override a client's intent is tracked separately. -func WithClientToken(t string) ClientOption { - return func(c *Client) { - c.token = &t - } -} - -// WithCheckRedirect sets the redirect policy for the client's requests. -// -// It is needed by callers that rewrite a redirect target, such as gh release -// download avoiding legacy Codeload paths, and by callers that halt redirects -// with http.ErrUseLastResponse, such as gh repo delete. Neither is expressible -// through a RequestOption, since a redirect policy belongs to the http.Client -// rather than to a request. -// -// The http.Client is shallow-copied and the policy set on the copy. This is not -// ceremony: NewClient stores the caller's *http.Client, and the CLI hands the -// same one to every command, so setting the field in place would install this -// policy process-wide for unrelated commands. Both call sites named above -// already hand-roll the same copy for the same reason. -func WithCheckRedirect(fn func(*http.Request, []*http.Request) error) ClientOption { - return func(c *Client) { - httpClient := &http.Client{} - if c.http != nil { - copied := *c.http - httpClient = &copied - } - httpClient.CheckRedirect = fn - c.http = httpClient - } -} - -// NewClient returns a Client that sends requests to apiBaseURL through -// httpClient. -// -// apiBaseURL is a resolved, absolute API base URL such as -// https://api.github.com/ or https://github.example.com/api/v3/, not a -// canonical host. It is rejected here rather than per request, so a Client -// either resolves relative paths correctly or does not exist. -// -// This takes a string and returns an error where the migration plan's Task 1 -// names apiBaseURL *url.URL and no error. The invariant is worth more than -// saving a caller a url.Parse, so a caller holding a parsed URL passes -// u.String(). -// -// Without WithClientToken the client sets no Authorization header, which means -// the transport supplies credentials rather than that requests carry none. -func NewClient(apiBaseURL string, httpClient *http.Client, opts ...ClientOption) (*Client, error) { - if apiBaseURL == "" { - return nil, errors.New("githubv3: apiBaseURL is required") - } - - parsed, err := url.Parse(apiBaseURL) - if err != nil { - return nil, fmt.Errorf("githubv3: parsing apiBaseURL: %w", err) - } - if parsed.Scheme == "" || parsed.Host == "" { - return nil, fmt.Errorf("githubv3: apiBaseURL %q is not absolute", apiBaseURL) - } - - client := &Client{ - apiBaseURL: parsed, - http: httpClient, - } - - for _, opt := range opts { - opt(client) - } - - return client, nil -} - -// RequestOption modifies a request built by NewRequest. -// -// It receives the *http.Request itself, so it is not limited to single-valued -// headers, and a caller who needs something no option covers can mutate the -// request between NewRequest and Send. -type RequestOption func(*http.Request) - -// WithHeader sets a request header, replacing any existing values for name. -func WithHeader(name, value string) RequestOption { - return func(req *http.Request) { - req.Header.Set(name, value) - } -} - -// WithToken overrides the client's token for a single request. -// -// It always sets the Authorization header, including when token is empty, -// mirroring WithClientToken: the request authenticates as exactly this token. -// Omitting it leaves the client's own token in place. There is deliberately no -// way to express "send no credentials" for the reasons given at -// WithClientToken. -func WithToken(token string) RequestOption { - return func(req *http.Request) { - req.Header.Set(authorizationHeader, authorizationValue(token)) - } -} - -// authorizationValue renders a token as an Authorization header value. -// -// An empty token still yields a non-empty value, which is what makes -// api.AddAuthTokenHeader and go-gh's header round tripper stand down: both -// check only whether Authorization is non-empty. -func authorizationValue(token string) string { - return fmt.Sprintf("token %s", token) -} - -// NewRequest builds a request against the client's host. -// -// path may be a path relative to the client's API base URL, or an absolute -// URL. Absolute URLs are required by callers that follow server-supplied URLs, -// such as release asset uploads. -// -// The Authorization header is set from the client's token, when it has one, -// before opts are applied, so an option can override it. A client with no token -// leaves the header unset for the transport to fill in. -// -// No other headers are set here. User-Agent, X-GitHub-Api-Version and, unless -// they are skipped, Accept, Content-Type and Time-Zone are applied by go-gh's -// header round tripper at RoundTrip time, and it skips any header already -// present on the request. An option therefore wins by being set earlier, not -// later, and setting defaults here would double-set them. -func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Reader, opts ...RequestOption) (*http.Request, error) { - req, err := http.NewRequestWithContext(ctx, method, c.resolveURL(path), body) - if err != nil { - return nil, err - } - - if c.token != nil { - req.Header.Set(authorizationHeader, authorizationValue(*c.token)) - } - - for _, opt := range opts { - opt(req) - } - - return req, nil -} - -// Send issues req and returns the response with its body still open, so the -// caller must close it. -// -// A non-2xx status yields a nil *Response and an *ErrorResponse. The body is -// closed on that path, because a caller holding no response has nothing to -// close. -func (c *Client) Send(req *http.Request) (*Response, error) { - resp, err := c.http.Do(req) - if err != nil { - return nil, err - } - - // net/http only populates Response.Request for its own transport, so a - // custom RoundTripper can leave it nil. Settling it here rather than in - // NewErrorResponse means the parser has one guarantee to reason about - // instead of two, and this response is ours to mutate. - if resp.Request == nil { - resp.Request = req - } - - if !isSuccess(resp.StatusCode) { - defer resp.Body.Close() - return nil, NewErrorResponse(resp) - } - - return &Response{Response: resp}, nil -} - -// Do issues req, decodes the JSON body into v, and closes the body. -// -// A nil v discards the body without decoding it. Statuses 204 and 205 skip -// decoding, since they carry no content. -// -// Once a response has been received successfully it is always returned, even -// alongside an error: the request succeeded and the status and headers are -// known good, so only body handling failed and the caller should keep them. -// The body of such a response is already closed, since Do closes it -// unconditionally, so only its metadata is readable. -// -// A non-2xx status yields a nil *Response and an *ErrorResponse. -func (c *Client) Do(req *http.Request, v any) (*Response, error) { - resp, err := c.Send(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusResetContent { - return resp, nil - } - - if v == nil { - // Drained rather than ignored so the connection can be reused. - _, err := io.Copy(io.Discard, resp.Body) - if err != nil { - return resp, err - } - return resp, nil - } - - b, err := io.ReadAll(resp.Body) - if err != nil { - return resp, err - } - - // An empty body reaches json.Unmarshal and fails with "unexpected end of - // JSON input". That is deliberate: it is what api.Client.REST and go-gh do - // today, so tolerating it the way google/go-github does would silently - // change behaviour at every existing call site. - if err := json.Unmarshal(b, v); err != nil { - return resp, err - } - - return resp, nil -} - -// resolveURL joins path to the client's API base URL, passing absolute URLs -// through untouched. -// -// The join is textual rather than url.URL.JoinPath, which percent-escapes the -// query separator and so would turn "issues?state=open" into -// "issues%3Fstate=open", silently breaking every filtered and paginated call -// site. go-gh concatenates for the same reason. -func (c *Client) resolveURL(path string) string { - if strings.HasPrefix(path, "https://") || strings.HasPrefix(path, "http://") { - return path - } - return strings.TrimSuffix(c.apiBaseURL.String(), "/") + "/" + strings.TrimPrefix(path, "/") -} - -func isSuccess(statusCode int) bool { - return statusCode >= 200 && statusCode < 300 -} From 3beea5004c3edb262d1a17494979d52a30ee6878 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 13:11:34 +0200 Subject: [PATCH 03/21] Justify Do's body handling on behaviour, not precedent Both comments argued from what api.Client.REST and go-gh happen to do today. That is a weak reason to keep a behaviour when the point of this package is to be a better abstraction, so both are now argued from what the caller gets. Draining a discarded body matters more than the old comment implied. Measured against httptest, closing a response body without reading it never reuses the connection, and the transport does no draining of its own even for a two byte body, so every subsequent request pays a fresh handshake. Inside Do this is an asymmetry rather than a general point: the decoding path already drains via ReadAll, so without the copy a nil v would be quietly slower than a typed one. An empty body stays an error, but for a better reason and with a better message. JSON can already express "nothing" as null, which decodes cleanly and leaves v at its zero value, so a body with nothing in it is a malformed response rather than an empty answer. go-github swallows it, which suits a client that covers the whole API generically, but here every call site targets a known endpoint. The caller now hears which status arrived with no body instead of "unexpected end of JSON input". Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 195575d5-99df-498d-bcfb-237299cac2d4 --- internal/githubrest/client.go | 23 ++++++++++++++++----- internal/githubrest/client_test.go | 32 +++++++++++++++++++++++++----- 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/internal/githubrest/client.go b/internal/githubrest/client.go index 811a2dcb354..92231c753cb 100644 --- a/internal/githubrest/client.go +++ b/internal/githubrest/client.go @@ -341,7 +341,10 @@ func (c *Client) Do(req *http.Request, v any) (*Response, error) { } if v == nil { - // Drained rather than ignored so the connection can be reused. + // Drained rather than ignored: the transport cannot reuse a connection + // with unread data on it, so closing early costs a TLS handshake on the + // next request. The decoding path drains naturally via ReadAll, so + // without this a nil v would be quietly slower than a typed one. _, err := io.Copy(io.Discard, resp.Body) if err != nil { return resp, err @@ -354,10 +357,20 @@ func (c *Client) Do(req *http.Request, v any) (*Response, error) { return resp, err } - // An empty body reaches json.Unmarshal and fails with "unexpected end of - // JSON input". That is deliberate: it is what api.Client.REST and go-gh do - // today, so tolerating it the way google/go-github does would silently - // change behaviour at every existing call site. + // A non-nil v states that the caller expects a JSON document, and an empty + // body is not one. JSON can already express "nothing" as null, which + // decodes cleanly and leaves v at its zero value, so a body with nothing at + // all in it is a malformed response rather than an empty answer. + // + // google/go-github tolerates this by swallowing io.EOF, which suits a + // client that covers the whole API generically and cannot audit each + // endpoint. Here every call site targets a known endpoint, so the caller is + // better served by hearing about it than by receiving a zero-valued struct + // that looks like a real answer. + if len(b) == 0 { + return resp, fmt.Errorf("githubrest: %d response had an empty body, but a JSON document was expected", resp.StatusCode) + } + if err := json.Unmarshal(b, v); err != nil { return resp, err } diff --git a/internal/githubrest/client_test.go b/internal/githubrest/client_test.go index 12ef95494bb..4a7d5401121 100644 --- a/internal/githubrest/client_test.go +++ b/internal/githubrest/client_test.go @@ -690,10 +690,10 @@ func TestDo(t *testing.T) { } }) - // api.Client.REST and go-gh both surface this as an error today. - // google/go-github swallows it instead, and matching that would silently - // change behaviour at every existing call site. - t.Run("an empty body with a non-nil v is an error", func(t *testing.T) { + // JSON can already say "nothing" as null, so an empty body is a malformed + // response rather than an empty answer, and a caller that asked for a value + // is better served by an error than by a zero-valued struct. + t.Run("an empty body with a non-nil v is an error naming the cause", func(t *testing.T) { client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { return stubResponse(req, http.StatusOK, io.NopCloser(strings.NewReader("")), nil) }) @@ -703,8 +703,30 @@ func TestDo(t *testing.T) { var target struct{} resp, err := client.Do(req, &target) - require.EqualError(t, err, "unexpected end of JSON input") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty body") + assert.Contains(t, err.Error(), "200", "the status is worth knowing, since a 2xx with no body is the surprise") + require.NotNil(t, resp) + }) + + // null is valid JSON and means the answer really is nothing, so it decodes + // rather than erroring, which is what distinguishes it from an empty body. + t.Run("a null body decodes to the zero value", func(t *testing.T) { + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { + return stubResponse(req, http.StatusOK, io.NopCloser(strings.NewReader("null")), nil) + }) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "repos/OWNER/REPO", nil) + require.NoError(t, err) + + target := struct { + Name string `json:"name"` + }{Name: "untouched"} + resp, err := client.Do(req, &target) + + require.NoError(t, err) require.NotNil(t, resp) + assert.Equal(t, "untouched", target.Name) }) t.Run("skips decoding on statuses without content", func(t *testing.T) { From 022610dbf696f6cf68431ff2af846b35c281ff23 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 14:12:25 +0200 Subject: [PATCH 04/21] Extend githubrest for the call sites it must serve Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- internal/githubrest/client.go | 198 +++++++++++++++++++++++------ internal/githubrest/client_test.go | 187 +++++++++++++++++++++++++-- internal/githubrest/errors.go | 31 ++++- internal/githubrest/errors_test.go | 54 ++++++++ internal/githubrest/http_client.go | 115 +++++++++++++++++ 5 files changed, 538 insertions(+), 47 deletions(-) create mode 100644 internal/githubrest/http_client.go diff --git a/internal/githubrest/client.go b/internal/githubrest/client.go index 92231c753cb..b520dcac966 100644 --- a/internal/githubrest/client.go +++ b/internal/githubrest/client.go @@ -8,6 +8,7 @@ package githubrest import ( + "bytes" "context" "encoding/json" "errors" @@ -15,7 +16,9 @@ import ( "io" "net/http" "net/url" + "slices" "strings" + "time" o "github.com/cli/cli/v2/pkg/option" ) @@ -23,8 +26,15 @@ import ( const ( authorizationHeader = "Authorization" contentTypeHeader = "Content-Type" + + // cacheTTLHeader is go-gh's cache control header, which its caching + // transport reads and strips before the request leaves. + cacheTTLHeader = "X-GH-CACHE-TTL" ) +// ErrPathTraversal is returned when a request path contains a ".." segment. +var ErrPathTraversal = errors.New("githubrest: path must not contain a .. segment") + // Client sends requests to a single GitHub REST API host. // // It holds an already-resolved API base URL: it reads no configuration and has @@ -35,6 +45,11 @@ type Client struct { token o.Option[string] http *http.Client + // noRedirect is http with a policy that stops at the first redirect. It is + // built at construction rather than on demand so that SendIgnoringRedirects + // never mutates shared state, and so a caller cannot forget to copy. + noRedirect *http.Client + // credentialedHosts are the hosts this client will send an Authorization // header to, lowercased and including any port. credentialedHosts map[string]struct{} @@ -97,26 +112,14 @@ func WithCredentialedHost(host string) ClientOption { // WithCheckRedirect sets the redirect policy for the client's requests. // -// It is needed by callers that rewrite a redirect target, such as gh release -// download avoiding legacy Codeload paths, and by callers that halt redirects -// with http.ErrUseLastResponse, such as gh repo delete. Neither is expressible -// through a RequestOption, since a redirect policy belongs to the http.Client -// rather than to a request. -// -// The http.Client is shallow-copied and the policy set on the copy. This is not -// ceremony: NewClient stores the caller's *http.Client, and the CLI hands the -// same one to every command, so setting the field in place would install this -// policy process-wide for unrelated commands. Both call sites named above -// already hand-roll the same copy for the same reason. +// Stopping at the first redirect has a method of its own in +// SendIgnoringRedirects, so this is for the one caller that rewrites a redirect +// target rather than refusing it: gh release download, avoiding legacy Codeload +// paths. A redirect policy belongs to the http.Client rather than to a request, +// so it cannot be a RequestOption. func WithCheckRedirect(fn func(*http.Request, []*http.Request) error) ClientOption { return func(c *Client) { - httpClient := &http.Client{} - if c.http != nil { - copied := *c.http - httpClient = &copied - } - httpClient.CheckRedirect = fn - c.http = httpClient + c.http = withRedirectPolicy(c.http, fn) } } @@ -160,9 +163,30 @@ func NewClient(apiBaseURL string, httpClient *http.Client, auth AuthStrategy, op opt(client) } + // Derived after opts, because WithCheckRedirect replaces client.http. + client.noRedirect = withRedirectPolicy(client.http, func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }) + return client, nil } +// withRedirectPolicy returns a shallow copy of httpClient carrying fn as its +// redirect policy. +// +// The copy is not ceremony: the CLI hands one *http.Client to every command, so +// setting the field in place would install the policy process-wide for +// unrelated commands. +func withRedirectPolicy(httpClient *http.Client, fn func(*http.Request, []*http.Request) error) *http.Client { + copied := &http.Client{} + if httpClient != nil { + c := *httpClient + copied = &c + } + copied.CheckRedirect = fn + return copied +} + // RequestOption modifies a request built by NewRequest. // // It receives the *http.Request itself, so it is not limited to single-valued @@ -177,8 +201,19 @@ func WithHeader(name, value string) RequestOption { } } -// WithSingleRequestToken overrides the client's token for one request. +// WithCacheTTL asks go-gh's caching transport to serve this request from cache +// for up to ttl. // +// The CLI's existing mechanism, api.NewCachedHTTPClient, derives a whole +// *http.Client to set one header, which forces a caching decision to be made +// where the client is assembled rather than where the request is made. Caching +// is a property of the request, so it is expressed as one. +func WithCacheTTL(ttl time.Duration) RequestOption { + return func(req *http.Request) { + req.Header.Set(cacheTTLHeader, ttl.String()) + } +} + // Like WithToken, an empty token still sets the header. This exists for callers // that hold one client but need a particular request to carry different // credentials; a caller whose every request uses the same token should say so @@ -290,13 +325,41 @@ func (c *Client) NewUploadRequest(ctx context.Context, path string, open func() } // Send issues req and returns the response with its body still open, so the -// caller must close it. +// caller must always close it. // -// A non-2xx status yields a nil *Response and an *ErrorResponse. The body is -// closed on that path, because a caller holding no response has nothing to -// close. +// A non-2xx status yields both the *Response and an *ErrorResponse. Returning +// the response alongside the error is what google/go-github does, and it is +// what lets a caller that cares about a failure's details reach them without a +// second, parallel send method. The cases that need it are real: gh api streams +// an error body to stdout verbatim, gh repo delete and gh gist create attach +// accepted scopes to the error, and repoExists and publishedReleaseExists read +// a 404 as a false rather than a failure. +// +// Unlike go-github, the body is not closed on the error path and its content is +// not truncated. go-github caps an error body at 1 MiB because it has already +// consumed it into the error; here the body is restored so a caller can read it +// again, which gh api depends on to print exactly what the server sent. Leaving +// it open in every case also means one rule rather than two: if Send returned a +// response, close it. func (c *Client) Send(req *http.Request) (*Response, error) { - resp, err := c.http.Do(req) + return c.send(c.http, req) +} + +// SendIgnoringRedirects issues req without following redirects, returning the +// redirect response itself. +// +// A redirect policy belongs to an http.Client rather than to a request, so it +// cannot be a RequestOption, and mutating the shared client would install the +// policy process-wide. google/go-github resolves this the same way: it builds a +// second http.Client at construction and exposes a method that uses it. A named +// method also says what the caller wants, where a general policy setter would +// only say how they intend to get it. +func (c *Client) SendIgnoringRedirects(req *http.Request) (*Response, error) { + return c.send(c.noRedirect, req) +} + +func (c *Client) send(httpClient *http.Client, req *http.Request) (*Response, error) { + resp, err := httpClient.Do(req) if err != nil { return nil, err } @@ -310,36 +373,76 @@ func (c *Client) Send(req *http.Request) (*Response, error) { } if !isSuccess(resp.StatusCode) { - defer resp.Body.Close() - return nil, NewErrorResponse(resp) + errResp, err := newErrorResponsePreservingBody(resp) + if err != nil { + return nil, err + } + return &Response{Response: resp}, errResp } return &Response{Response: resp}, nil } -// Do issues req, decodes the JSON body into v, and closes the body. +// newErrorResponsePreservingBody parses resp into an *ErrorResponse and leaves +// resp.Body readable from the start. +// +// Parsing consumes the body, so it is buffered and replaced. Buffering is +// bounded by what the server sent, and only on a failure, which is the price of +// letting a caller both read a structured error and print the raw one. +func newErrorResponsePreservingBody(resp *http.Response) (*ErrorResponse, error) { + b, err := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if err != nil { + return nil, err + } + if closeErr != nil { + return nil, closeErr + } + + resp.Body = io.NopCloser(bytes.NewReader(b)) + errResp := NewErrorResponse(resp) + resp.Body = io.NopCloser(bytes.NewReader(b)) + + return errResp, nil +} + +// Do issues req, reads the response body into v, and closes the body. // -// A nil v discards the body without decoding it. Statuses 204 and 205 skip -// decoding, since they carry no content. +// v decides how the body is read. An io.Writer receives it verbatim, which is +// what diffs, patches, logs and downloaded archives need; anything else is a +// JSON decode target; and a nil v discards it. The io.Writer case follows +// google/go-github, and it is why callers that want raw bytes do not need a +// separate method. // -// Once a response has been received successfully it is always returned, even -// alongside an error: the request succeeded and the status and headers are -// known good, so only body handling failed and the caller should keep them. -// The body of such a response is already closed, since Do closes it -// unconditionally, so only its metadata is readable. +// Statuses 204 and 205 skip reading, since they carry no content. // -// A non-2xx status yields a nil *Response and an *ErrorResponse. +// Once a response has been received it is always returned, even alongside an +// error, including the *ErrorResponse for a non-2xx status. The request +// succeeded and the status and headers are known good. The body of such a +// response is already closed, since Do closes it unconditionally, so only its +// metadata is readable. func (c *Client) Do(req *http.Request, v any) (*Response, error) { resp, err := c.Send(req) - if err != nil { + if resp == nil { return nil, err } defer resp.Body.Close() + if err != nil { + return resp, err + } + if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusResetContent { return resp, nil } + if w, ok := v.(io.Writer); ok { + if _, err := io.Copy(w, resp.Body); err != nil { + return resp, err + } + return resp, nil + } + if v == nil { // Drained rather than ignored: the transport cannot reuse a connection // with unread data on it, so closing early costs a TLS handshake on the @@ -386,6 +489,10 @@ func (c *Client) Do(req *http.Request, v any) (*Response, error) { // "issues%3Fstate=open", silently breaking every filtered and paginated call // site. go-gh concatenates for the same reason. func (c *Client) resolveURL(path string) (string, error) { + if err := checkPathTraversal(path); err != nil { + return "", err + } + if !strings.HasPrefix(path, "https://") && !strings.HasPrefix(path, "http://") { return strings.TrimSuffix(c.apiBaseURL.String(), "/") + "/" + strings.TrimPrefix(path, "/"), nil } @@ -411,3 +518,22 @@ func (c *Client) resolveURL(path string) (string, error) { func isSuccess(statusCode int) bool { return statusCode >= 200 && statusCode < 300 } + +// checkPathTraversal rejects a path containing a ".." segment. +// +// A traversal segment can climb out of the endpoint a caller named, turning +// "repos/OWNER/REPO/../../user" into a request nobody at the call site intended +// and, worse, one that still carries the token. google/go-github rejects the +// same shape. Percent-encoded forms are covered because url.Parse decodes them +// before the check runs. ".." inside a segment, as in "file..txt", is a +// legitimate name and is left alone. +func checkPathTraversal(path string) error { + parsed, err := url.Parse(path) + if err != nil { + return fmt.Errorf("githubrest: parsing path %q: %w", path, err) + } + if slices.Contains(strings.Split(parsed.Path, "/"), "..") { + return ErrPathTraversal + } + return nil +} diff --git a/internal/githubrest/client_test.go b/internal/githubrest/client_test.go index 4a7d5401121..d04ba3d0595 100644 --- a/internal/githubrest/client_test.go +++ b/internal/githubrest/client_test.go @@ -560,7 +560,7 @@ func TestSend(t *testing.T) { assert.Equal(t, http.StatusCreated, resp.StatusCode) }) - t.Run("closes the body on the error path", func(t *testing.T) { + t.Run("returns the response alongside the error, with the body readable", func(t *testing.T) { body := newTrackedBody(`{"message":"Not Found"}`) client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { header := http.Header{"Content-Type": []string{"application/json"}} @@ -570,10 +570,24 @@ func TestSend(t *testing.T) { req, err := client.NewRequest(context.Background(), http.MethodGet, "repos/OWNER/REPO", nil) require.NoError(t, err) - resp, err := client.Send(req) - require.Error(t, err) - assert.Nil(t, resp) - assert.True(t, body.isClosed(), "Send must close the body when the caller gets no response to close") + resp, sendErr := client.Send(req) + require.Error(t, sendErr) + require.NotNil(t, resp, "callers that read a 404 as a false, or print the raw body, need the response") + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + + // gh api prints exactly what the server sent, so parsing the error must + // not have consumed the body. + read, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, `{"message":"Not Found"}`, string(read)) + + assert.True(t, body.isClosed(), "the original body is closed once buffered") + + var errResp *ErrorResponse + require.ErrorAs(t, sendErr, &errResp) + assert.Equal(t, "Not Found", errResp.Message) }) t.Run("returns transport errors unchanged", func(t *testing.T) { @@ -774,7 +788,8 @@ func TestDo(t *testing.T) { var target struct{} resp, err := client.Do(req, &target) require.Error(t, err) - assert.Nil(t, resp) + require.NotNil(t, resp, "the status and headers are known good even when the status is not") + assert.Equal(t, http.StatusUnprocessableEntity, resp.StatusCode) var errResp *ErrorResponse require.ErrorAs(t, err, &errResp) @@ -874,9 +889,8 @@ func TestWithCheckRedirect(t *testing.T) { assert.Equal(t, "rewritten", target.Name, "the policy's rewrite must take effect") }) - // gh repo delete halts redirects this way, and then treats anything over - // 299 as an error. Send reports the halted 302 as an *ErrorResponse because - // isSuccess is 200-299, which is the same outcome by a different route. + // gh release download rewrites a redirect target this way. Halting them + // outright has its own method, SendIgnoringRedirects. t.Run("the policy can halt redirects, and a halted 302 is an ErrorResponse", func(t *testing.T) { server := newRedirectServer(t) @@ -890,7 +904,8 @@ func TestWithCheckRedirect(t *testing.T) { resp, err := client.Send(req) require.Error(t, err) - assert.Nil(t, resp) + require.NotNil(t, resp) + defer resp.Body.Close() var errResp *ErrorResponse require.ErrorAs(t, err, &errResp) @@ -1128,3 +1143,155 @@ func (r *recordingCloser) Close() error { r.closed = true return nil } + +func TestSendIgnoringRedirects(t *testing.T) { + newRedirectServer := func(t *testing.T) *httptest.Server { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/repos/OWNER/REPO", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/repos/OWNER/RENAMED", http.StatusMovedPermanently) + }) + mux.HandleFunc("/repos/OWNER/RENAMED", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server + } + + // gh repo delete must not follow a rename redirect, because deleting + // whatever a redirect points at is not what the user asked for. + t.Run("stops at the first redirect and reports it as an ErrorResponse", func(t *testing.T) { + server := newRedirectServer(t) + client := newTestClient(t, server.URL, server.Client(), WithoutToken()) + + req, err := client.NewRequest(context.Background(), http.MethodDelete, "repos/OWNER/REPO", nil) + require.NoError(t, err) + + resp, err := client.SendIgnoringRedirects(req) + require.Error(t, err) + require.NotNil(t, resp) + defer resp.Body.Close() + + assert.Equal(t, http.StatusMovedPermanently, resp.StatusCode) + + var errResp *ErrorResponse + require.ErrorAs(t, err, &errResp) + assert.Equal(t, "/repos/OWNER/RENAMED", errResp.Headers.Get("Location")) + }) + + t.Run("leaves the client's own redirect behaviour alone", func(t *testing.T) { + server := newRedirectServer(t) + client := newTestClient(t, server.URL, server.Client(), WithoutToken()) + + ignoring, err := client.NewRequest(context.Background(), http.MethodDelete, "repos/OWNER/REPO", nil) + require.NoError(t, err) + _, err = client.SendIgnoringRedirects(ignoring) + require.Error(t, err) + + following, err := client.NewRequest(context.Background(), http.MethodDelete, "repos/OWNER/REPO", nil) + require.NoError(t, err) + + resp, err := client.Send(following) + require.NoError(t, err, "Send must still follow redirects after SendIgnoringRedirects was used") + defer resp.Body.Close() + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + }) + + // The CLI hands one *http.Client to every command, so a policy set in place + // rather than on a copy would leak into unrelated commands. + t.Run("does not mutate the caller's http.Client", func(t *testing.T) { + server := newRedirectServer(t) + httpClient := server.Client() + client := newTestClient(t, server.URL, httpClient, WithoutToken()) + + req, err := client.NewRequest(context.Background(), http.MethodDelete, "repos/OWNER/REPO", nil) + require.NoError(t, err) + _, err = client.SendIgnoringRedirects(req) + require.Error(t, err) + + assert.Nil(t, httpClient.CheckRedirect) + }) +} + +func TestDoIntoWriter(t *testing.T) { + // gh pr diff, gh run view --log and artifact downloads all want the bytes + // the server sent, not a JSON document. + t.Run("copies the raw body into an io.Writer", func(t *testing.T) { + body := newTrackedBody("diff --git a/f b/f\n") + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { + return stubResponse(req, http.StatusOK, body, nil) + }) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "repos/OWNER/REPO/pulls/1", nil) + require.NoError(t, err) + + var out strings.Builder + resp, err := client.Do(req, &out) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "diff --git a/f b/f\n", out.String()) + assert.True(t, body.isClosed()) + }) + + // The empty-body error exists to catch a JSON endpoint returning nothing. + // A writer has no such expectation, so an empty body is simply no bytes. + t.Run("an empty body is not an error for a writer", func(t *testing.T) { + client, _ := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { + return stubResponse(req, http.StatusOK, io.NopCloser(strings.NewReader("")), nil) + }) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "repos/OWNER/REPO/logs", nil) + require.NoError(t, err) + + var out strings.Builder + _, err = client.Do(req, &out) + require.NoError(t, err) + assert.Empty(t, out.String()) + }) +} + +func TestWithCacheTTL(t *testing.T) { + client, captured := stubClient(t, "https://api.github.com/", WithoutToken(), func(req *http.Request) *http.Response { + return stubResponse(req, http.StatusOK, io.NopCloser(strings.NewReader("{}")), nil) + }) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil, WithCacheTTL(time.Hour)) + require.NoError(t, err) + + _, err = client.Do(req, nil) + require.NoError(t, err) + + assert.Equal(t, "1h0m0s", (*captured).Header.Get("X-GH-CACHE-TTL")) +} + +func TestPathTraversal(t *testing.T) { + client := newTestClient(t, "https://api.github.com/", &http.Client{}, WithoutToken()) + + tests := []struct { + name string + path string + wantErr bool + }{ + {name: "a relative path with a traversal segment", path: "repos/OWNER/REPO/../../user", wantErr: true}, + {name: "a percent-encoded traversal segment", path: "repos/OWNER/%2e%2e/user", wantErr: true}, + {name: "a leading traversal segment", path: "../user", wantErr: true}, + {name: "an absolute URL with a traversal segment", path: "https://api.github.com/repos/../user", wantErr: true}, + {name: "dots inside a segment are a legitimate name", path: "repos/OWNER/REPO/contents/file..txt", wantErr: false}, + {name: "a query string is not a path", path: "search/issues?q=a..b", wantErr: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := client.NewRequest(context.Background(), http.MethodGet, tt.path, nil) + if tt.wantErr { + require.ErrorIs(t, err, ErrPathTraversal) + return + } + require.NoError(t, err) + }) + } +} diff --git a/internal/githubrest/errors.go b/internal/githubrest/errors.go index a95536581dd..54c895f770c 100644 --- a/internal/githubrest/errors.go +++ b/internal/githubrest/errors.go @@ -34,6 +34,10 @@ type ErrorResponse struct { Message string RequestURL *url.URL StatusCode int + + // requiredScopes are scopes the endpoint needs but did not report, as + // declared by RequireScopes. + requiredScopes []string } // Error allows ErrorResponse to satisfy the error interface. @@ -64,14 +68,39 @@ func (e *ErrorResponse) ScopesSuggestion() string { hostname = e.RequestURL.Hostname() } + accepted := e.Headers.Get(acceptedScopesHeader) + if len(e.requiredScopes) > 0 { + accepted = strings.TrimPrefix(accepted+", "+strings.Join(e.requiredScopes, ", "), ", ") + } + return generateScopesSuggestion( e.StatusCode, - e.Headers.Get(acceptedScopesHeader), + accepted, e.Headers.Get(tokenScopesHeader), hostname, ) } +// RequireScopes declares scopes the endpoint needs but does not report in +// X-Accepted-Oauth-Scopes, and returns the receiver so it can be used inline. +// +// Some endpoints simply do not list the scopes they require, so a 4xx from them +// produces no suggestion at all and the user is told only that they lack +// access. The CLI's existing answer, api.EndpointNeedsScopes, forges the header +// on the response as though the server had sent it. Recording it on the error +// instead keeps the response an honest record of what arrived, and puts the +// claim where the thing that consumes it already lives. +// +// It is safe to call on a nil receiver, because callers reach it holding a nil +// pointer after a failed errors.As. +func (e *ErrorResponse) RequireScopes(scopes ...string) *ErrorResponse { + if e == nil { + return nil + } + e.requiredScopes = append(e.requiredScopes, scopes...) + return e +} + // NewErrorResponse parses a non-2xx response into an *ErrorResponse. // // It exists for callers that already hold a response and need the error for it, diff --git a/internal/githubrest/errors_test.go b/internal/githubrest/errors_test.go index 76ad1cc41c0..5356aabbffb 100644 --- a/internal/githubrest/errors_test.go +++ b/internal/githubrest/errors_test.go @@ -338,3 +338,57 @@ func TestNewErrorResponse(t *testing.T) { "an empty URL would read as a real but blank URL, which is why nil is kept") }) } + +func TestRequireScopes(t *testing.T) { + // gh repo delete and gh gist create hit endpoints that never report the + // scope they need, so without this the user is told only that they lack + // access. + t.Run("produces a suggestion for an endpoint that reports no scopes", func(t *testing.T) { + errResp := &ErrorResponse{ + StatusCode: http.StatusForbidden, + Headers: http.Header{"X-Oauth-Scopes": []string{"repo"}}, + RequestURL: mustParseURL(t, "https://api.github.com/repos/OWNER/REPO"), + } + + assert.Empty(t, errResp.ScopesSuggestion()) + + errResp.RequireScopes("delete_repo") + assert.Equal( + t, + "This API operation needs the \"delete_repo\" scope. To request it, run: gh auth refresh -h github.com -s delete_repo", + errResp.ScopesSuggestion(), + ) + }) + + t.Run("adds to scopes the endpoint did report", func(t *testing.T) { + errResp := &ErrorResponse{ + StatusCode: http.StatusForbidden, + Headers: http.Header{ + "X-Accepted-Oauth-Scopes": []string{"repo"}, + "X-Oauth-Scopes": []string{"repo"}, + }, + RequestURL: mustParseURL(t, "https://api.github.com/gists"), + } + + errResp.RequireScopes("gist") + assert.Contains(t, errResp.ScopesSuggestion(), "gh auth refresh -h github.com -s gist") + }) + + t.Run("returns the receiver so it can be used inline", func(t *testing.T) { + errResp := &ErrorResponse{StatusCode: http.StatusForbidden} + assert.Same(t, errResp, errResp.RequireScopes("gist")) + }) + + // Callers reach this holding a nil pointer after a failed errors.As. + t.Run("is safe on a nil receiver", func(t *testing.T) { + var errResp *ErrorResponse + assert.Nil(t, errResp.RequireScopes("gist")) + }) +} + +func mustParseURL(t *testing.T, s string) *url.URL { + t.Helper() + u, err := url.Parse(s) + require.NoError(t, err) + return u +} diff --git a/internal/githubrest/http_client.go b/internal/githubrest/http_client.go new file mode 100644 index 00000000000..80fd4371bfc --- /dev/null +++ b/internal/githubrest/http_client.go @@ -0,0 +1,115 @@ +package githubrest + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/utils" + ghAPI "github.com/cli/go-gh/v2/pkg/api" + ghauth "github.com/cli/go-gh/v2/pkg/auth" +) + +const ( + apiVersionHeader = "X-GitHub-Api-Version" + apiVersionValue = "2022-11-28" + userAgentHeader = "User-Agent" +) + +// HTTPClientOptions configures the transport a Client sends through. +type HTTPClientOptions struct { + AppVersion string + CacheTTL time.Duration + EnableCache bool + InvokingAgent string + Log io.Writer + LogColorize bool + LogVerboseHTTP bool + SkipDefaultHeaders bool + TelemetryDisabler ghtelemetry.Disabler +} + +// NewHTTPClient returns the transport stack a Client sends through: go-gh's +// caching, logging and default-header round trippers, plus the CLI's telemetry +// disabler. +// +// It deliberately does not attach an authentication transport. api.NewHTTPClient +// wraps its client in AddAuthTokenHeader, which resolves a token per request +// from the request's host, so nothing at a call site says which credentials a +// request will carry. Here the token is settled when the Client is constructed, +// which is the point of this package, and a transport that filled in a token for +// an unauthenticated client would quietly undo that. +// +// api.NewHTTPClient survives for GraphQL, which still injects per request. The +// duplication is deliberate and temporary: the eventual shape is one shared +// constructor with REST and GraphQL clients beside it. +func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) { + // Host and token are given deliberately invalid values so that go-gh does + // not resolve real ones from the environment. This client only carries + // transport concerns; the Client supplies the Authorization header. + clientOpts := ghAPI.ClientOptions{ + Host: "none", + AuthToken: "none", + LogIgnoreEnv: true, + SkipDefaultHeaders: opts.SkipDefaultHeaders, + } + + debugEnabled, debugValue := utils.IsDebugEnabled() + if strings.Contains(debugValue, "api") { + opts.LogVerboseHTTP = true + } + + if opts.LogVerboseHTTP || debugEnabled { + clientOpts.Log = opts.Log + clientOpts.LogColorize = opts.LogColorize + clientOpts.LogVerboseHTTP = opts.LogVerboseHTTP + } + + ua := fmt.Sprintf("GitHub CLI %s", opts.AppVersion) + if opts.InvokingAgent != "" { + ua = fmt.Sprintf("%s Agent/%s", ua, opts.InvokingAgent) + } + + clientOpts.Headers = map[string]string{ + userAgentHeader: ua, + apiVersionHeader: apiVersionValue, + } + + if opts.EnableCache { + clientOpts.EnableCache = opts.EnableCache + clientOpts.CacheTTL = opts.CacheTTL + } + + client, err := ghAPI.NewHTTPClient(clientOpts) + if err != nil { + return nil, err + } + + if opts.TelemetryDisabler != nil { + client.Transport = telemetryDisablerTransport{ + wrappedTransport: client.Transport, + telemetryDisabler: opts.TelemetryDisabler, + } + } + + return client, nil +} + +type telemetryDisablerTransport struct { + wrappedTransport http.RoundTripper + telemetryDisabler ghtelemetry.Disabler +} + +func (t telemetryDisablerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + host := req.Host + if host == "" { + host = req.URL.Host + } + if ghauth.IsEnterprise(host) { + t.telemetryDisabler.Disable() + } + return t.wrappedTransport.RoundTrip(req) +} From 219e14b0de5e548c45952057711600efcbf52194 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 14:17:18 +0200 Subject: [PATCH 05/21] Vend githubrest clients from the factory Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- internal/ghinstance/host.go | 18 ++++++ internal/ghinstance/host_test.go | 19 ++++++ pkg/cmd/factory/default.go | 87 ++++++++++++++++++++++++++ pkg/cmd/factory/default_test.go | 102 +++++++++++++++++++++++++++++++ pkg/cmdutil/factory.go | 16 +++++ pkg/httpmock/githubrest.go | 38 ++++++++++++ 6 files changed, 280 insertions(+) create mode 100644 pkg/httpmock/githubrest.go diff --git a/internal/ghinstance/host.go b/internal/ghinstance/host.go index dfea6dd946b..306d58db328 100644 --- a/internal/ghinstance/host.go +++ b/internal/ghinstance/host.go @@ -69,6 +69,24 @@ func RESTPrefix(hostname string) string { return fmt.Sprintf("https://api.%s/", hostname) } +// UploadHost returns the host that serves the REST upload endpoint for +// hostname. +// +// On an Enterprise Server or garage instance uploads live on the instance +// itself; elsewhere they live on their own subdomain, mirroring how RESTPrefix +// puts the API on "api.". Callers need this because a release's upload_url +// points at that host, so a client must be told it belongs to the same +// deployment before it will send a token there. +func UploadHost(hostname string) string { + if isGarage(hostname) { + return hostname + } + if ghauth.IsEnterprise(hostname) { + return hostname + } + return "uploads." + hostname +} + func GistPrefix(hostname string) string { prefix := "https://" if strings.EqualFold(hostname, localhost) { diff --git a/internal/ghinstance/host_test.go b/internal/ghinstance/host_test.go index c4f447780a9..14d7950795e 100644 --- a/internal/ghinstance/host_test.go +++ b/internal/ghinstance/host_test.go @@ -211,3 +211,22 @@ func TestCategorizeHost(t *testing.T) { }) } } + +func TestUploadHost(t *testing.T) { + tests := []struct { + hostname string + want string + }{ + {hostname: "github.com", want: "uploads.github.com"}, + {hostname: "github.localhost", want: "uploads.github.localhost"}, + {hostname: "tenant.ghe.com", want: "uploads.tenant.ghe.com"}, + {hostname: "garage.github.com", want: "garage.github.com"}, + {hostname: "github.example.com", want: "github.example.com"}, + } + + for _, tt := range tests { + t.Run(tt.hostname, func(t *testing.T) { + assert.Equal(t, tt.want, UploadHost(tt.hostname)) + }) + } +} diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index cc10075f203..1e0b5a7b4ee 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "regexp" + "sync" "time" "github.com/cli/cli/v2/api" @@ -13,11 +14,14 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmd/extension" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + ghauth "github.com/cli/go-gh/v2/pkg/auth" ) var ssoHeader string @@ -33,6 +37,9 @@ func New(appVersion string, invokingAgent string, cfgFunc func() (gh.Config, err f.IOStreams = ios f.HttpClient = HttpClientFunc(cfgFunc, ios, appVersion, invokingAgent, telemetryDisabler) + restClients := newGitHubRESTClientFactory(cfgFunc, ios, appVersion, invokingAgent, telemetryDisabler) + f.GitHubREST = restClients.Authenticated + f.GitHubRESTAnonymous = restClients.Anonymous f.PlainHttpClient = plainHttpClientFunc(ios, appVersion, invokingAgent, telemetryDisabler) f.ExternalHttpClient = externalHttpClientFunc(ios, appVersion) f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable @@ -185,6 +192,86 @@ func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) { return rr.Resolver() } +// gitHubRESTClientFactory builds githubrest.Clients that share one transport. +// +// The transport is built once and reused, because it carries the HTTP cache and +// connection pool and building one per host would throw both away. Clients +// themselves are not cached: a client is a base URL, a token and a set of +// options, so constructing one is cheap, and caching by host alone would hand +// back a client carrying options a previous caller asked for. +type gitHubRESTClientFactory struct { + cfgFunc func() (gh.Config, error) + + httpClientOnce sync.Once + httpClient *http.Client + httpClientErr error + newHTTPClient func() (*http.Client, error) +} + +func newGitHubRESTClientFactory(cfgFunc func() (gh.Config, error), ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) *gitHubRESTClientFactory { + return &gitHubRESTClientFactory{ + cfgFunc: cfgFunc, + newHTTPClient: func() (*http.Client, error) { + client, err := githubrest.NewHTTPClient(githubrest.HTTPClientOptions{ + AppVersion: appVersion, + InvokingAgent: invokingAgent, + Log: ios.ErrOut, + LogColorize: ios.ColorEnabled(), + TelemetryDisabler: telemetryDisabler, + }) + if err != nil { + return nil, err + } + client.Transport = api.ExtractHeader("X-GitHub-SSO", &ssoHeader)(client.Transport) + return client, nil + }, + } +} + +func (f *gitHubRESTClientFactory) sharedHTTPClient() (*http.Client, error) { + f.httpClientOnce.Do(func() { + f.httpClient, f.httpClientErr = f.newHTTPClient() + }) + return f.httpClient, f.httpClientErr +} + +// Authenticated returns a client for host carrying that host's active token. +func (f *gitHubRESTClientFactory) Authenticated(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { + cfg, err := f.cfgFunc() + if err != nil { + return nil, err + } + + // An empty token is passed through rather than turned into WithoutToken. + // The two are different: a request with an empty credential is rejected as + // unauthorized, where an unauthenticated one may succeed against public + // data, and a command that asked for an authenticated client should hear + // the former. + token, _ := cfg.Authentication().ActiveToken(ghauth.NormalizeHostname(host)) + return f.newClient(host, githubrest.WithToken(token), opts...) +} + +// Anonymous returns a client for host that sets no Authorization header. +func (f *gitHubRESTClientFactory) Anonymous(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { + return f.newClient(host, githubrest.WithoutToken(), opts...) +} + +func (f *gitHubRESTClientFactory) newClient(host string, auth githubrest.AuthStrategy, opts ...githubrest.ClientOption) (*githubrest.Client, error) { + httpClient, err := f.sharedHTTPClient() + if err != nil { + return nil, err + } + + // Uploads may live on a sibling host, which is a deployment accident rather + // than anything a call site should reason about, so it is declared here + // where the deployment is already known. + opts = append([]githubrest.ClientOption{ + githubrest.WithCredentialedHost(ghinstance.UploadHost(host)), + }, opts...) + + return githubrest.NewClient(ghinstance.RESTPrefix(host), httpClient, auth, opts...) +} + func HttpClientFunc(cfgFunc func() (gh.Config, error), ios *iostreams.IOStreams, appVersion string, invokingAgent string, telemetryDisabler ghtelemetry.Disabler) func() (*http.Client, error) { return func() (*http.Client, error) { cfg, err := cfgFunc() diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index 9cf34f3b0e5..4042080424b 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -1,6 +1,7 @@ package factory import ( + "context" "net/http" "net/http/httptest" "net/url" @@ -11,6 +12,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" ghmock "github.com/cli/cli/v2/internal/gh/mock" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -443,3 +445,103 @@ func defaultConfig() *ghmock.ConfigMock { cfg.Set("nonsense.com", "oauth_token", "BLAH") return cfg } + +func TestGitHubRESTClientFactory(t *testing.T) { + newFactory := func(t *testing.T, cfg gh.Config) *gitHubRESTClientFactory { + t.Helper() + ios, _, _, _ := iostreams.Test() + return newGitHubRESTClientFactory(func() (gh.Config, error) { return cfg, nil }, ios, "v1.2.3", "", &telemetry.NoOpService{}) + } + + // AuthConfig's token override lives on the instance, and cfg.Authentication() + // hands back a fresh one each call, so the same instance has to be pinned. + configWithToken := func(token string) gh.Config { + authCfg := config.NewBlankConfig().Authentication() + authCfg.SetActiveToken(token, "oauth_token") + return &ghmock.ConfigMock{ + AuthenticationFunc: func() gh.AuthConfig { return authCfg }, + } + } + + t.Run("resolves the API base URL from the host", func(t *testing.T) { + tests := []struct { + host string + wantURL string + }{ + {host: "github.com", wantURL: "https://api.github.com/user"}, + {host: "github.example.com", wantURL: "https://github.example.com/api/v3/user"}, + } + + for _, tt := range tests { + t.Run(tt.host, func(t *testing.T) { + f := newFactory(t, config.NewBlankConfig()) + + client, err := f.Authenticated(tt.host) + require.NoError(t, err) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil) + require.NoError(t, err) + assert.Equal(t, tt.wantURL, req.URL.String()) + }) + } + }) + + // The point of constructing per host is that the token is settled here + // rather than filled in per request by a transport. + t.Run("sets the Authorization header from the host's active token", func(t *testing.T) { + f := newFactory(t, configWithToken("a-token")) + + client, err := f.Authenticated("github.com") + require.NoError(t, err) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil) + require.NoError(t, err) + assert.Equal(t, "token a-token", req.Header.Get("Authorization")) + }) + + t.Run("an anonymous client sets no Authorization header", func(t *testing.T) { + f := newFactory(t, configWithToken("a-token")) + + client, err := f.Anonymous("github.com") + require.NoError(t, err) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "user", nil) + require.NoError(t, err) + assert.Empty(t, req.Header.Get("Authorization")) + }) + + // A release's upload_url points at a sibling host on github.com, and a + // client refuses to send its token to a host it was not told about. + t.Run("credentials the upload host", func(t *testing.T) { + f := newFactory(t, config.NewBlankConfig()) + + client, err := f.Authenticated("github.com") + require.NoError(t, err) + + _, err = client.NewRequest(context.Background(), http.MethodPost, "https://uploads.github.com/repos/OWNER/REPO/releases/1/assets?name=x", nil) + require.NoError(t, err) + }) + + t.Run("passes caller options through", func(t *testing.T) { + f := newFactory(t, config.NewBlankConfig()) + + client, err := f.Authenticated("github.com", githubrest.WithCredentialedHost("codeload.github.com")) + require.NoError(t, err) + + _, err = client.NewRequest(context.Background(), http.MethodGet, "https://codeload.github.com/OWNER/REPO/zip", nil) + require.NoError(t, err) + }) + + // The transport carries the HTTP cache and connection pool, so rebuilding + // it per host would throw both away. + t.Run("shares one transport across clients", func(t *testing.T) { + f := newFactory(t, config.NewBlankConfig()) + + first, err := f.sharedHTTPClient() + require.NoError(t, err) + second, err := f.sharedHTTPClient() + require.NoError(t, err) + + assert.Same(t, first, second) + }) +} diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index 200314038b2..b817637284c 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" @@ -35,6 +36,21 @@ type Factory struct { // We need to revisit that, but I don't want to make it worse. Config func() (gh.Config, error) HttpClient func() (*http.Client, error) + // GitHubREST returns a GitHub REST API client for host, authenticated as + // that host's active user. + // + // It takes the host rather than being a plain func() because the host is + // usually not known until a command has resolved BaseRepo or read a + // --host flag, and a few commands span hosts. Asking for a client at the + // moment the host is known is what lets the token be settled once, at + // construction, instead of being filled in per request by a transport. + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) + // GitHubRESTAnonymous returns a GitHub REST API client for host that sends + // no token of its own. + // + // This is for requests that must not carry the user's credentials, such as + // the release check in internal/update. + GitHubRESTAnonymous func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) // PlainHttpClient is a special HTTP client that does not automatically set // auth and other headers. This is meant to be used in situations where the // client needs to specify the headers itself (e.g. during login). diff --git a/pkg/httpmock/githubrest.go b/pkg/httpmock/githubrest.go new file mode 100644 index 00000000000..a6535c2d3c2 --- /dev/null +++ b/pkg/httpmock/githubrest.go @@ -0,0 +1,38 @@ +package httpmock + +import ( + "net/http" + + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/githubrest" +) + +// RESTClientFunc returns a stand-in for cmdutil.Factory.GitHubREST that serves +// every host from reg. +// +// Command tests set Options.GitHubREST to this rather than building a client by +// hand, because the construction is identical everywhere and repeating it puts +// base URL resolution into hundreds of test files, where a drift from the real +// factory would go unnoticed. +// +// The token is a fixed placeholder. A test that cares which credentials are +// sent should assert on the request's Authorization header, and one that needs +// a particular token should build its own client. +func RESTClientFunc(reg *Registry) func(string, ...githubrest.ClientOption) (*githubrest.Client, error) { + return restClientFunc(reg, githubrest.WithToken("test-token")) +} + +// RESTClientFuncAnonymous is RESTClientFunc for +// cmdutil.Factory.GitHubRESTAnonymous, sending no token. +func RESTClientFuncAnonymous(reg *Registry) func(string, ...githubrest.ClientOption) (*githubrest.Client, error) { + return restClientFunc(reg, githubrest.WithoutToken()) +} + +func restClientFunc(reg *Registry, auth githubrest.AuthStrategy) func(string, ...githubrest.ClientOption) (*githubrest.Client, error) { + return func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { + opts = append([]githubrest.ClientOption{ + githubrest.WithCredentialedHost(ghinstance.UploadHost(host)), + }, opts...) + return githubrest.NewClient(ghinstance.RESTPrefix(host), &http.Client{Transport: reg}, auth, opts...) + } +} From 1911851fb16a4bcc12d13070f4406b1f5642b0ee Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 14:25:04 +0200 Subject: [PATCH 06/21] Replace api.HTTPError with githubrest.ErrorResponse One error type across migrated and unmigrated REST call sites, so no call site has to handle both while the migration is in flight. Also restructures the error chain in ghcmd, which reached ScopesSuggestion on a value left behind by a failed errors.As. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- api/client.go | 37 +++++++++---------- api/client_test.go | 9 +++-- internal/ghcmd/cmd.go | 23 ++++++++---- internal/ghcmd/cmd_test.go | 11 ++---- internal/skills/discovery/discovery.go | 3 +- pkg/cmd/attestation/api/client.go | 3 +- .../api/mock_githubApiClient_test.go | 7 ++-- pkg/cmd/cache/delete/delete.go | 3 +- pkg/cmd/extension/http.go | 7 ++-- pkg/cmd/extension/http_test.go | 4 +- pkg/cmd/gist/create/create.go | 3 +- pkg/cmd/gist/delete/delete.go | 3 +- pkg/cmd/gist/shared/shared.go | 3 +- pkg/cmd/gpg-key/add/add_test.go | 4 +- pkg/cmd/gpg-key/add/http.go | 3 +- pkg/cmd/gpg-key/delete/delete_test.go | 6 +-- pkg/cmd/gpg-key/list/http.go | 3 +- pkg/cmd/gpg-key/list/list_test.go | 4 +- pkg/cmd/label/create.go | 7 ++-- pkg/cmd/pr/merge/merge.go | 4 +- pkg/cmd/release/create/http.go | 5 ++- pkg/cmd/release/create/http_test.go | 4 +- pkg/cmd/release/shared/upload.go | 3 +- pkg/cmd/repo/autolink/create/http.go | 3 +- pkg/cmd/repo/autolink/create/http_test.go | 4 +- pkg/cmd/repo/autolink/delete/http.go | 3 +- pkg/cmd/repo/autolink/delete/http_test.go | 4 +- pkg/cmd/repo/autolink/list/http.go | 3 +- pkg/cmd/repo/autolink/list/http_test.go | 4 +- pkg/cmd/repo/autolink/view/http.go | 3 +- pkg/cmd/repo/autolink/view/http_test.go | 4 +- pkg/cmd/repo/delete/delete.go | 5 ++- pkg/cmd/repo/deploy-key/add/add_test.go | 4 +- pkg/cmd/repo/deploy-key/delete/delete_test.go | 4 +- pkg/cmd/repo/deploy-key/list/list_test.go | 4 +- pkg/cmd/repo/gitignore/view/view.go | 3 +- pkg/cmd/repo/license/view/view.go | 3 +- pkg/cmd/repo/sync/http.go | 3 +- pkg/cmd/repo/sync/sync.go | 3 +- pkg/cmd/repo/view/http.go | 3 +- pkg/cmd/run/cancel/cancel.go | 5 ++- pkg/cmd/run/delete/delete.go | 5 ++- pkg/cmd/run/rerun/rerun.go | 5 ++- pkg/cmd/run/shared/shared.go | 5 ++- pkg/cmd/skills/search/search.go | 3 +- pkg/cmd/ssh-key/add/add_test.go | 4 +- pkg/cmd/ssh-key/delete/delete_test.go | 6 +-- pkg/cmd/ssh-key/list/list.go | 4 +- pkg/cmd/ssh-key/shared/user_keys_test.go | 4 +- pkg/cmd/status/status.go | 7 ++-- pkg/cmd/variable/get/get.go | 3 +- pkg/cmd/variable/set/http.go | 3 +- pkg/cmd/workflow/shared/shared.go | 5 ++- pkg/cmd/workflow/shared/shared_test.go | 31 +++++++--------- pkg/cmd/workflow/view/view.go | 3 +- 55 files changed, 172 insertions(+), 140 deletions(-) diff --git a/api/client.go b/api/client.go index 27a747995c9..7b6365d07cb 100644 --- a/api/client.go +++ b/api/client.go @@ -10,6 +10,7 @@ import ( "regexp" "strings" + "github.com/cli/cli/v2/internal/githubrest" ghAPI "github.com/cli/go-gh/v2/pkg/api" ghauth "github.com/cli/go-gh/v2/pkg/auth" ) @@ -43,15 +44,6 @@ type GraphQLError struct { *ghAPI.GraphQLError } -type HTTPError struct { - *ghAPI.HTTPError - scopesSuggestion string -} - -func (err HTTPError) ScopesSuggestion() string { - return err.scopesSuggestion -} - // GraphQL performs a GraphQL request using the query string and parses the response into data receiver. If there are errors in the response, // GraphQLError will be returned, but the receiver will also be partially populated. func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error { @@ -147,15 +139,19 @@ func (c Client) RESTWithNext(hostname string, method string, p string, body io.R return next, nil } -// HandleHTTPError parses a http.Response into a HTTPError. +// HandleHTTPError parses a http.Response into a *githubrest.ErrorResponse. // // The caller is responsible to close the response body stream. func HandleHTTPError(resp *http.Response) error { - return handleResponse(ghAPI.HandleHTTPError(resp)) + return githubrest.NewErrorResponse(resp) } -// handleResponse takes a ghAPI.HTTPError or ghAPI.GraphQLError and converts it into an -// HTTPError or GraphQLError respectively. +// handleResponse takes a ghAPI.HTTPError or ghAPI.GraphQLError and converts it into a +// *githubrest.ErrorResponse or GraphQLError respectively. +// +// Returning githubrest's error type from the api package is interop scaffolding: +// it means migrated and unmigrated call sites agree on one error type while the +// REST migration is in flight, so no call site has to handle both. func handleResponse(err error) error { if err == nil { return nil @@ -163,13 +159,16 @@ func handleResponse(err error) error { var restErr *ghAPI.HTTPError if errors.As(err, &restErr) { - return HTTPError{ - HTTPError: restErr, - scopesSuggestion: generateScopesSuggestion(restErr.StatusCode, - restErr.Headers.Get("X-Accepted-Oauth-Scopes"), - restErr.Headers.Get("X-Oauth-Scopes"), - restErr.RequestURL.Hostname()), + errResp := &githubrest.ErrorResponse{ + Headers: restErr.Headers, + Message: restErr.Message, + RequestURL: restErr.RequestURL, + StatusCode: restErr.StatusCode, + } + for _, item := range restErr.Errors { + errResp.Errors = append(errResp.Errors, githubrest.ErrorItem(item)) } + return errResp } var gqlErr *ghAPI.GraphQLError diff --git a/api/client_test.go b/api/client_test.go index bf7a93d85b7..c57708354fb 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "testing" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -125,7 +126,7 @@ func TestRESTError(t *testing.T) { }, nil }) - var httpErr HTTPError + var httpErr *githubrest.ErrorResponse err := client.REST("github.com", "DELETE", "repos/branch", nil, nil) if err == nil || !errors.As(err, &httpErr) { t.Fatalf("got %v", err) @@ -159,7 +160,7 @@ func TestRESTWithNextError(t *testing.T) { _, err := client.RESTWithNext("github.com", http.MethodGet, "repos/owner/repo/items", nil, nil) - var httpErr HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) assert.Contains(t, err.Error(), "HTTP 404") @@ -250,7 +251,7 @@ func TestHandleHTTPError_GraphQL502(t *testing.T) { } } -func TestHTTPError_ScopesSuggestion(t *testing.T) { +func TestErrorResponse_ScopesSuggestion(t *testing.T) { makeResponse := func(s int, u, haveScopes, needScopes string) *http.Response { req, err := http.NewRequest("GET", u, nil) if err != nil { @@ -312,7 +313,7 @@ func TestHTTPError_ScopesSuggestion(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { httpError := HandleHTTPError(tt.resp) - if got := httpError.(HTTPError).ScopesSuggestion(); got != tt.want { + if got := httpError.(*githubrest.ErrorResponse).ScopesSuggestion(); got != tt.want { t.Errorf("HTTPError.ScopesSuggestion() = %v, want %v", got, tt.want) } }) diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index b7e15bd5f4e..10179bf15b3 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -16,7 +16,6 @@ import ( surveyCore "github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/terminal" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/agents" "github.com/cli/cli/v2/internal/build" "github.com/cli/cli/v2/internal/ci" @@ -24,6 +23,7 @@ import ( "github.com/cli/cli/v2/internal/config/migration" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/telemetry" "github.com/cli/cli/v2/internal/update" "github.com/cli/cli/v2/pkg/cmd/auth/shared" @@ -230,18 +230,25 @@ func Main() exitCode { return exitError } - var httpErr api.HTTPError - if errors.As(err, &httpErr) && httpErr.StatusCode == 401 { + // The scopes suggestion is only meaningful when the error really is an + // API error, so the chain tests that explicitly rather than reaching the + // last branch holding whatever errors.As left behind. + var httpErr *githubrest.ErrorResponse + isAPIErr := errors.As(err, &httpErr) + switch { + case isAPIErr && httpErr.StatusCode == 401: authCommand := "gh auth login" if cfg, cfgErr := cmdFactory.Config(); cfgErr == nil { authCommand = authRecoveryCommand(cfg, httpErr) } fmt.Fprintf(stderr, "Try authenticating with: %s\n", authCommand) - } else if u := factory.SSOURL(); u != "" { + case factory.SSOURL() != "": // handles organization SAML enforcement error - fmt.Fprintf(stderr, "Authorize in your web browser: %s\n", u) - } else if msg := httpErr.ScopesSuggestion(); msg != "" { - fmt.Fprintln(stderr, msg) + fmt.Fprintf(stderr, "Authorize in your web browser: %s\n", factory.SSOURL()) + case isAPIErr: + if msg := httpErr.ScopesSuggestion(); msg != "" { + fmt.Fprintln(stderr, msg) + } } return exitError @@ -300,7 +307,7 @@ func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) { } } -func authRecoveryCommand(cfg gh.Config, httpErr api.HTTPError) string { +func authRecoveryCommand(cfg gh.Config, httpErr *githubrest.ErrorResponse) string { if httpErr.RequestURL == nil { return "gh auth login" } diff --git a/internal/ghcmd/cmd_test.go b/internal/ghcmd/cmd_test.go index d389bd7448f..97eb5b034dd 100644 --- a/internal/ghcmd/cmd_test.go +++ b/internal/ghcmd/cmd_test.go @@ -8,12 +8,11 @@ import ( "net/url" "testing" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" ghmock "github.com/cli/cli/v2/internal/gh/mock" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmdutil" - ghAPI "github.com/cli/go-gh/v2/pkg/api" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" ) @@ -572,11 +571,9 @@ func Test_authRecoveryCommand(t *testing.T) { } } - httpErr := api.HTTPError{ - HTTPError: &ghAPI.HTTPError{ - RequestURL: requestURL, - StatusCode: 401, - }, + httpErr := &githubrest.ErrorResponse{ + RequestURL: requestURL, + StatusCode: 401, } got := authRecoveryCommand(cfg, httpErr) diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 694662900e6..995ce703a6f 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -16,6 +16,7 @@ import ( "sync/atomic" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/skills/frontmatter" "github.com/cli/cli/v2/pkg/iostreams" @@ -325,7 +326,7 @@ func resolveBranchRef(client *api.Client, host, owner, repo, branch string) (*Re // isNotFound returns true if the error is an HTTP 404 response. func isNotFound(err error) bool { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse return errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound } diff --git a/pkg/cmd/attestation/api/client.go b/pkg/cmd/attestation/api/client.go index 0cb3a5a1e81..745da03ee16 100644 --- a/pkg/cmd/attestation/api/client.go +++ b/pkg/cmd/attestation/api/client.go @@ -11,6 +11,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ioconfig "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/klauspost/compress/snappy" @@ -280,7 +281,7 @@ func (c *LiveClient) getBundle(url safeurl.SafeURL) (*bundle.Bundle, error) { } func shouldRetry(err error) bool { - var httpError api.HTTPError + var httpError *githubrest.ErrorResponse if errors.As(err, &httpError) { if httpError.StatusCode >= 500 && httpError.StatusCode <= 599 { return true diff --git a/pkg/cmd/attestation/api/mock_githubApiClient_test.go b/pkg/cmd/attestation/api/mock_githubApiClient_test.go index fa9be7e7f59..9d84aef20eb 100644 --- a/pkg/cmd/attestation/api/mock_githubApiClient_test.go +++ b/pkg/cmd/attestation/api/mock_githubApiClient_test.go @@ -7,8 +7,7 @@ import ( "io" "strings" - cliAPI "github.com/cli/cli/v2/api" - ghAPI "github.com/cli/go-gh/v2/pkg/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/stretchr/testify/mock" ) @@ -58,7 +57,7 @@ func (m *mockDataGenerator) FlakyOnRESTSuccessWithNextPageHandler() func(hostnam m.MethodCalled("FlakyOnRESTSuccessWithNextPage:error") count = count + 1 - return "", cliAPI.HTTPError{HTTPError: &ghAPI.HTTPError{StatusCode: 500}} + return "", &githubrest.ErrorResponse{StatusCode: 500} } else { count = count + 1 return m.OnRESTSuccessWithNextPage(hostname, method, p, body, data) @@ -72,7 +71,7 @@ func (m *mockDataGenerator) OnREST500ErrorHandler() func(hostname, method, p str return func(hostname, method, p string, body io.Reader, data interface{}) (string, error) { m.MethodCalled("OnREST500Error") - return "", cliAPI.HTTPError{HTTPError: &ghAPI.HTTPError{StatusCode: 500}} + return "", &githubrest.ErrorResponse{StatusCode: 500} } } diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go index 6bf28f76419..155eb2b3825 100644 --- a/pkg/cmd/cache/delete/delete.go +++ b/pkg/cmd/cache/delete/delete.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/cache/shared" @@ -173,7 +174,7 @@ func deleteCaches(opts *DeleteOptions, client *api.Client, repo ghrepo.Interface } if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { if httpErr.StatusCode == http.StatusNotFound { if opts.Ref == "" { diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go index 333564d29a4..689f717c43a 100644 --- a/pkg/cmd/extension/http.go +++ b/pkg/cmd/extension/http.go @@ -10,6 +10,7 @@ import ( "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/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -55,7 +56,7 @@ func hasScript(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { // 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, nil) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { return false, nil } @@ -128,7 +129,7 @@ func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*re // 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 + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { return nil, releaseNotFoundErr } @@ -156,7 +157,7 @@ func fetchReleaseFromTag(httpClient *http.Client, baseRepo ghrepo.Interface, tag // 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 + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { return nil, releaseNotFoundErr } diff --git a/pkg/cmd/extension/http_test.go b/pkg/cmd/extension/http_test.go index 7a262f78026..22e029eeac2 100644 --- a/pkg/cmd/extension/http_test.go +++ b/pkg/cmd/extension/http_test.go @@ -5,8 +5,8 @@ import ( "net/http" "testing" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -29,7 +29,7 @@ func extensionHTTPClient(t *testing.T, path string, status int, body string) *ht func requireExtensionHTTPError(t *testing.T, err error, status int) { t.Helper() - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, status, httpErr.StatusCode) assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", status)) diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index 6ed4f66263d..9bc19360e0e 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -18,6 +18,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/gist/shared" @@ -157,7 +158,7 @@ func createRun(opts *CreateOptions) error { gist, err := createGist(httpClient, host, opts.Description, opts.Public, files) opts.IO.StopProgressIndicator() if err != nil { - var httpError api.HTTPError + var httpError *githubrest.ErrorResponse if errors.As(err, &httpError) { if httpError.StatusCode == http.StatusUnprocessableEntity { if detectEmptyFiles(files) { diff --git a/pkg/cmd/gist/delete/delete.go b/pkg/cmd/gist/delete/delete.go index 4413bc8bffb..0143a409eeb 100644 --- a/pkg/cmd/gist/delete/delete.go +++ b/pkg/cmd/gist/delete/delete.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/gist/shared" @@ -149,7 +150,7 @@ func deleteGist(apiClient *api.Client, hostname string, gistID string) error { } err = apiClient.REST(hostname, "DELETE", path.String(), nil, nil) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { return shared.NotFoundErr } diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go index 7c0a7c07565..efbf2e3ed80 100644 --- a/pkg/cmd/gist/shared/shared.go +++ b/pkg/cmd/gist/shared/shared.go @@ -12,6 +12,7 @@ import ( "time" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" @@ -71,7 +72,7 @@ func GetGist(client *http.Client, hostname, gistID string) (*Gist, error) { apiClient := api.NewClientFromHTTP(client) err = apiClient.REST(hostname, "GET", path.String(), nil, &gist) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { return nil, NotFoundErr } diff --git a/pkg/cmd/gpg-key/add/add_test.go b/pkg/cmd/gpg-key/add/add_test.go index 45119bdfda3..e2f9a879e26 100644 --- a/pkg/cmd/gpg-key/add/add_test.go +++ b/pkg/cmd/gpg-key/add/add_test.go @@ -6,9 +6,9 @@ import ( "testing" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -82,7 +82,7 @@ func Test_gpgKeyUploadHTTPError(t *testing.T) { err := gpgKeyUpload(&http.Client{Transport: reg}, "github.com", strings.NewReader("-----BEGIN PGP PUBLIC KEY BLOCK-----"), "") - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, http.StatusInternalServerError, httpErr.StatusCode) assert.Contains(t, err.Error(), "HTTP 500") diff --git a/pkg/cmd/gpg-key/add/http.go b/pkg/cmd/gpg-key/add/http.go index 41b220a4d57..634cc640619 100644 --- a/pkg/cmd/gpg-key/add/http.go +++ b/pkg/cmd/gpg-key/add/http.go @@ -8,6 +8,7 @@ import ( "net/http" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -44,7 +45,7 @@ func gpgKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t apiClient := api.NewClientFromHTTP(httpClient) err = apiClient.REST(hostname, "POST", path.String(), bytes.NewBuffer(payloadBytes), nil) if err != nil { - if httpError, ok := errors.AsType[api.HTTPError](err); ok { + if httpError, ok := errors.AsType[*githubrest.ErrorResponse](err); ok { if httpError.StatusCode == 404 { return errScopesMissing } diff --git a/pkg/cmd/gpg-key/delete/delete_test.go b/pkg/cmd/gpg-key/delete/delete_test.go index ef3b36f64ca..1e1df4ba1a4 100644 --- a/pkg/cmd/gpg-key/delete/delete_test.go +++ b/pkg/cmd/gpg-key/delete/delete_test.go @@ -6,9 +6,9 @@ import ( "net/url" "testing" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -32,7 +32,7 @@ func Test_deleteGPGKeyHTTPError(t *testing.T) { err := deleteGPGKey(&http.Client{Transport: reg}, "github.com", "123") - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, http.StatusInternalServerError, httpErr.StatusCode) assert.Contains(t, err.Error(), "HTTP 500") @@ -53,7 +53,7 @@ func Test_getGPGKeysHTTPError(t *testing.T) { keys, err := getGPGKeys(&http.Client{Transport: reg}, "github.com") assert.Nil(t, keys) - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, http.StatusInternalServerError, httpErr.StatusCode) assert.Contains(t, err.Error(), "HTTP 500") diff --git a/pkg/cmd/gpg-key/list/http.go b/pkg/cmd/gpg-key/list/http.go index 8a282711025..dd9afef0dd6 100644 --- a/pkg/cmd/gpg-key/list/http.go +++ b/pkg/cmd/gpg-key/list/http.go @@ -7,6 +7,7 @@ import ( "time" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -53,7 +54,7 @@ func userKeys(httpClient *http.Client, host, userHandle string) ([]gpgKey, error // As a follow up, consider whether the api client can be injected to this call site, rather than constructed err = api.NewClientFromHTTP(httpClient).REST(host, "GET", u.String(), nil, &keys) if err != nil { - if httpErr, ok := errors.AsType[api.HTTPError](err); ok && httpErr.StatusCode == 404 { + if httpErr, ok := errors.AsType[*githubrest.ErrorResponse](err); ok && httpErr.StatusCode == 404 { return nil, errScopes } return nil, err diff --git a/pkg/cmd/gpg-key/list/list_test.go b/pkg/cmd/gpg-key/list/list_test.go index cf9a9b45d25..71e9f121140 100644 --- a/pkg/cmd/gpg-key/list/list_test.go +++ b/pkg/cmd/gpg-key/list/list_test.go @@ -8,9 +8,9 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -45,7 +45,7 @@ func Test_userKeysHTTPError(t *testing.T) { keys, err := userKeys(&http.Client{Transport: reg}, "github.com", "") assert.Nil(t, keys) - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, http.StatusInternalServerError, httpErr.StatusCode) assert.Contains(t, err.Error(), "HTTP 500") diff --git a/pkg/cmd/label/create.go b/pkg/cmd/label/create.go index 58954372989..f63c3f83da8 100644 --- a/pkg/cmd/label/create.go +++ b/pkg/cmd/label/create.go @@ -13,6 +13,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -143,7 +144,7 @@ func createLabel(client *http.Client, repo ghrepo.Interface, opts *createOptions requestBody := bytes.NewReader(requestByte) err = apiClient.REST(repo.RepoHost(), "POST", path.String(), requestBody, nil) - if httpError, ok := err.(api.HTTPError); ok && isLabelAlreadyExistsError(httpError) { + if httpError, ok := err.(*githubrest.ErrorResponse); ok && isLabelAlreadyExistsError(httpError) { err = errLabelAlreadyExists } @@ -181,13 +182,13 @@ func updateLabel(apiClient *api.Client, repo ghrepo.Interface, opts *editOptions requestBody := bytes.NewReader(requestByte) err = apiClient.REST(repo.RepoHost(), "PATCH", path.String(), requestBody, nil) - if httpError, ok := err.(api.HTTPError); ok && isLabelAlreadyExistsError(httpError) { + if httpError, ok := err.(*githubrest.ErrorResponse); ok && isLabelAlreadyExistsError(httpError) { err = errLabelAlreadyExists } return err } -func isLabelAlreadyExistsError(err api.HTTPError) bool { +func isLabelAlreadyExistsError(err *githubrest.ErrorResponse) bool { return err.StatusCode == 422 && len(err.Errors) == 1 && err.Errors[0].Field == "name" && err.Errors[0].Code == "already_exists" } diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 2049b85ae95..3b26ab74c1f 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -467,7 +468,8 @@ func (m *mergeContext) deleteRemoteBranch() error { // error because the goal is already achieved. var isAlreadyDeletedError bool - if httpErr := (api.HTTPError{}); errors.As(err, &httpErr) { + var httpErr *githubrest.ErrorResponse + if errors.As(err, &httpErr) { // TODO: since the API returns 422 for a couple of other reasons, for more accuracy // we might want to check the error message against "Reference does not exist". isAlreadyDeletedError = httpErr.StatusCode == http.StatusUnprocessableEntity || httpErr.StatusCode == http.StatusNotFound diff --git a/pkg/cmd/release/create/http.go b/pkg/cmd/release/create/http.go index a2dc4372cda..86f37087d4f 100644 --- a/pkg/cmd/release/create/http.go +++ b/pkg/cmd/release/create/http.go @@ -13,6 +13,7 @@ import ( "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/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/shurcooL/githubv4" @@ -100,7 +101,7 @@ func generateReleaseNotes(httpClient *http.Client, repo ghrepo.Interface, tagNam // 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.MethodPost, path.String(), bytes.NewBuffer(bodyBytes), &rn) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { return nil, notImplementedError } @@ -166,7 +167,7 @@ func createRelease(httpClient *http.Client, repo ghrepo.Interface, params map[st // 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.MethodPost, path.String(), bytes.NewBuffer(bodyBytes), &newRelease) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound && !tokenHasWorkflowScope(httpErr.Headers) { diff --git a/pkg/cmd/release/create/http_test.go b/pkg/cmd/release/create/http_test.go index 4c38ed88370..ca016333948 100644 --- a/pkg/cmd/release/create/http_test.go +++ b/pkg/cmd/release/create/http_test.go @@ -5,8 +5,8 @@ import ( "net/http" "testing" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" @@ -164,7 +164,7 @@ func TestTokenHasWorkflowScope(t *testing.T) { func requireAPIHTTPError(t *testing.T, err error, statusCode int) { t.Helper() - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, statusCode, httpErr.StatusCode) assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", statusCode)) diff --git a/pkg/cmd/release/shared/upload.go b/pkg/cmd/release/shared/upload.go index 9307c8c01c2..c20d4db3962 100644 --- a/pkg/cmd/release/shared/upload.go +++ b/pkg/cmd/release/shared/upload.go @@ -15,6 +15,7 @@ import ( "github.com/cenkalti/backoff/v4" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" "golang.org/x/sync/errgroup" @@ -136,7 +137,7 @@ func shouldRetry(err error) bool { if errors.As(err, &networkError) { return true } - var httpError api.HTTPError + var httpError *githubrest.ErrorResponse return errors.As(err, &httpError) && httpError.StatusCode >= 500 } diff --git a/pkg/cmd/repo/autolink/create/http.go b/pkg/cmd/repo/autolink/create/http.go index 73a84e0cff4..ccff49e4fcb 100644 --- a/pkg/cmd/repo/autolink/create/http.go +++ b/pkg/cmd/repo/autolink/create/http.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" ) @@ -40,7 +41,7 @@ func (a *AutolinkCreator) Create(repo ghrepo.Interface, request AutolinkCreateRe // 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 { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { httpErr.Message = "Must have admin rights to Repository." return nil, httpErr diff --git a/pkg/cmd/repo/autolink/create/http_test.go b/pkg/cmd/repo/autolink/create/http_test.go index 1fc913d8fbf..e07d74b9f2d 100644 --- a/pkg/cmd/repo/autolink/create/http_test.go +++ b/pkg/cmd/repo/autolink/create/http_test.go @@ -6,8 +6,8 @@ import ( "testing" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" @@ -152,7 +152,7 @@ func TestAutolinkCreator_Create(t *testing.T) { if tt.expectErr { require.EqualError(t, err, tt.expectedErrMsg) - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, tt.expectedStatus, httpErr.StatusCode) } else { diff --git a/pkg/cmd/repo/autolink/delete/http.go b/pkg/cmd/repo/autolink/delete/http.go index 54e25148d59..f73796bb5e3 100644 --- a/pkg/cmd/repo/autolink/delete/http.go +++ b/pkg/cmd/repo/autolink/delete/http.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -25,7 +26,7 @@ func (a *AutolinkDeleter) Delete(repo ghrepo.Interface, id string) error { // 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 + var httpErr *githubrest.ErrorResponse 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) } diff --git a/pkg/cmd/repo/autolink/delete/http_test.go b/pkg/cmd/repo/autolink/delete/http_test.go index 5451ed1ca5f..96025ba4d25 100644 --- a/pkg/cmd/repo/autolink/delete/http_test.go +++ b/pkg/cmd/repo/autolink/delete/http_test.go @@ -5,8 +5,8 @@ import ( "net/http" "testing" - cliapi "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" ghapi "github.com/cli/go-gh/v2/pkg/api" "github.com/stretchr/testify/assert" @@ -72,7 +72,7 @@ func TestAutolinkDeleter_Delete(t *testing.T) { if tt.expectErr { require.EqualError(t, err, tt.expectedErrMsg) if tt.expectedStatus != 0 { - var httpErr cliapi.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, tt.expectedStatus, httpErr.StatusCode) } diff --git a/pkg/cmd/repo/autolink/list/http.go b/pkg/cmd/repo/autolink/list/http.go index 3058807bc58..ef92df5b3bf 100644 --- a/pkg/cmd/repo/autolink/list/http.go +++ b/pkg/cmd/repo/autolink/list/http.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" ) @@ -27,7 +28,7 @@ func (a *AutolinkLister) List(repo ghrepo.Interface) ([]shared.Autolink, error) // 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 + var httpErr *githubrest.ErrorResponse 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) } diff --git a/pkg/cmd/repo/autolink/list/http_test.go b/pkg/cmd/repo/autolink/list/http_test.go index a52dcf00b06..714719e4409 100644 --- a/pkg/cmd/repo/autolink/list/http_test.go +++ b/pkg/cmd/repo/autolink/list/http_test.go @@ -5,8 +5,8 @@ import ( "net/http" "testing" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" @@ -100,7 +100,7 @@ func TestAutolinkLister_List(t *testing.T) { if tt.expectedErrMsg != "" { require.EqualError(t, err, tt.expectedErrMsg) if tt.expectedStatus != 0 { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, tt.expectedStatus, httpErr.StatusCode) } diff --git a/pkg/cmd/repo/autolink/view/http.go b/pkg/cmd/repo/autolink/view/http.go index 1d9ef16dfde..aa409b9a968 100644 --- a/pkg/cmd/repo/autolink/view/http.go +++ b/pkg/cmd/repo/autolink/view/http.go @@ -7,6 +7,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" ) @@ -27,7 +28,7 @@ func (a *AutolinkViewer) View(repo ghrepo.Interface, id string) (*shared.Autolin // 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 + var httpErr *githubrest.ErrorResponse 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) } diff --git a/pkg/cmd/repo/autolink/view/http_test.go b/pkg/cmd/repo/autolink/view/http_test.go index 7ebc0326624..eb5f30c8f4f 100644 --- a/pkg/cmd/repo/autolink/view/http_test.go +++ b/pkg/cmd/repo/autolink/view/http_test.go @@ -5,8 +5,8 @@ import ( "net/http" "testing" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/repo/autolink/shared" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" @@ -105,7 +105,7 @@ func TestAutolinkViewer_View(t *testing.T) { if tt.expectErr { require.EqualError(t, err, tt.expectedErrMsg) if tt.expectedStatus != 0 { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, tt.expectedStatus, httpErr.StatusCode) } diff --git a/pkg/cmd/repo/delete/delete.go b/pkg/cmd/repo/delete/delete.go index 6724191dacf..28a55a3404a 100644 --- a/pkg/cmd/repo/delete/delete.go +++ b/pkg/cmd/repo/delete/delete.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" ghauth "github.com/cli/go-gh/v2/pkg/auth" @@ -123,9 +124,9 @@ func deleteRun(opts *DeleteOptions) error { err = deleteRepo(httpClient, toDelete) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { - statusCode := httpErr.HTTPError.StatusCode + statusCode := httpErr.StatusCode if statusCode == http.StatusMovedPermanently || statusCode == http.StatusTemporaryRedirect || statusCode == http.StatusPermanentRedirect { diff --git a/pkg/cmd/repo/deploy-key/add/add_test.go b/pkg/cmd/repo/deploy-key/add/add_test.go index 8eda3e5b732..1b68a691435 100644 --- a/pkg/cmd/repo/deploy-key/add/add_test.go +++ b/pkg/cmd/repo/deploy-key/add/add_test.go @@ -5,8 +5,8 @@ import ( "strings" "testing" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -105,7 +105,7 @@ func TestUploadDeployKeyHTTPError(t *testing.T) { false, ) - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) assert.Contains(t, err.Error(), "HTTP 404") diff --git a/pkg/cmd/repo/deploy-key/delete/delete_test.go b/pkg/cmd/repo/deploy-key/delete/delete_test.go index 30a6e83a249..2521ecd82f4 100644 --- a/pkg/cmd/repo/deploy-key/delete/delete_test.go +++ b/pkg/cmd/repo/deploy-key/delete/delete_test.go @@ -4,8 +4,8 @@ import ( "net/http" "testing" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -56,7 +56,7 @@ func TestDeleteDeployKeyHTTPError(t *testing.T) { "1234", ) - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) assert.Contains(t, err.Error(), "HTTP 404") diff --git a/pkg/cmd/repo/deploy-key/list/list_test.go b/pkg/cmd/repo/deploy-key/list/list_test.go index 50298f44980..1c87c1979b3 100644 --- a/pkg/cmd/repo/deploy-key/list/list_test.go +++ b/pkg/cmd/repo/deploy-key/list/list_test.go @@ -7,8 +7,8 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -149,7 +149,7 @@ func TestRepoKeysHTTPError(t *testing.T) { ghrepo.New("OWNER", "REPO"), ) - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) assert.Contains(t, err.Error(), "HTTP 404") diff --git a/pkg/cmd/repo/gitignore/view/view.go b/pkg/cmd/repo/gitignore/view/view.go index fcf83700ac1..3340b29cc7e 100644 --- a/pkg/cmd/repo/gitignore/view/view.go +++ b/pkg/cmd/repo/gitignore/view/view.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -87,7 +88,7 @@ func viewRun(opts *ViewOptions) error { hostname, _ := cfg.Authentication().DefaultHost() gitIgnore, err := api.RepoGitIgnoreTemplate(client, hostname, opts.Template) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { if httpErr.StatusCode == 404 { return fmt.Errorf("'%s' is not a valid gitignore template. Run `gh repo gitignore list` for options", opts.Template) diff --git a/pkg/cmd/repo/license/view/view.go b/pkg/cmd/repo/license/view/view.go index 14228ba3600..87f6e9ae0f4 100644 --- a/pkg/cmd/repo/license/view/view.go +++ b/pkg/cmd/repo/license/view/view.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -95,7 +96,7 @@ func viewRun(opts *ViewOptions) error { hostname, _ := cfg.Authentication().DefaultHost() license, err := api.RepoLicense(client, hostname, opts.License) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { if httpErr.StatusCode == 404 { return fmt.Errorf("'%s' is not a valid license name or SPDX ID.\n\nRun `gh repo license list` to see available commonly used licenses. For even more licenses, visit %s", opts.License, text.DisplayURL("https://choosealicense.com/appendix")) diff --git a/pkg/cmd/repo/sync/http.go b/pkg/cmd/repo/sync/http.go index 86cc0468851..7059d4137e6 100644 --- a/pkg/cmd/repo/sync/http.go +++ b/pkg/cmd/repo/sync/http.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -56,7 +57,7 @@ func triggerUpstreamMerge(client *api.Client, repo ghrepo.Interface, branch stri if err != nil { return "", err } - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if err := client.REST(repo.RepoHost(), "POST", path.String(), &payload, &response); err != nil { if errors.As(err, &httpErr) { switch httpErr.StatusCode { diff --git a/pkg/cmd/repo/sync/sync.go b/pkg/cmd/repo/sync/sync.go index b025d9eec26..72b95408560 100644 --- a/pkg/cmd/repo/sync/sync.go +++ b/pkg/cmd/repo/sync/sync.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/context" gitpkg "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -315,7 +316,7 @@ func executeRemoteRepoSync(client *api.Client, destRepo, srcRepo ghrepo.Interfac // endpoint but unfortunately the API returns 422 for many reasons so we must // interpret the message provide better error messaging for our users. err = syncFork(client, destRepo, branchName, commit.Object.SHA, opts.Force) - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if err != nil { if errors.As(err, &httpErr) { switch httpErr.Message { diff --git a/pkg/cmd/repo/view/http.go b/pkg/cmd/repo/view/http.go index 14aef095f82..0e477b9e95d 100644 --- a/pkg/cmd/repo/view/http.go +++ b/pkg/cmd/repo/view/http.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/go-gh/v2/pkg/asciisanitizer" "golang.org/x/text/transform" @@ -38,7 +39,7 @@ func RepositoryReadme(client *http.Client, repo ghrepo.Interface, branch string) err = apiClient.REST(repo.RepoHost(), "GET", readmePath.String(), nil, &response) if err != nil { - var httpError api.HTTPError + var httpError *githubrest.ErrorResponse if errors.As(err, &httpError) && httpError.StatusCode == 404 { return nil, NotFoundError } diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go index 296ca9f7ba1..168921bf663 100644 --- a/pkg/cmd/run/cancel/cancel.go +++ b/pkg/cmd/run/cancel/cancel.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -109,7 +110,7 @@ func runCancel(opts *CancelOptions) error { } else { run, err = shared.GetRun(client, repo, runID, 0) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { if httpErr.StatusCode == http.StatusNotFound { err = fmt.Errorf("Could not find any workflow run with ID %s", opts.RunID) @@ -123,7 +124,7 @@ func runCancel(opts *CancelOptions) error { err = cancelWorkflowRun(client, repo, fmt.Sprintf("%d", run.ID), force) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { if httpErr.StatusCode == http.StatusConflict { err = fmt.Errorf("Cannot cancel a workflow run that is completed") diff --git a/pkg/cmd/run/delete/delete.go b/pkg/cmd/run/delete/delete.go index 6fc8e17b722..5a2bfaf6988 100644 --- a/pkg/cmd/run/delete/delete.go +++ b/pkg/cmd/run/delete/delete.go @@ -8,6 +8,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/run/shared" @@ -112,7 +113,7 @@ func runDelete(opts *DeleteOptions) error { } else { run, err = shared.GetRun(client, repo, runID, 0) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { if httpErr.StatusCode == http.StatusNotFound { err = fmt.Errorf("could not find any workflow run with ID %s", opts.RunID) @@ -124,7 +125,7 @@ func runDelete(opts *DeleteOptions) error { err = deleteWorkflowRun(client, repo, fmt.Sprintf("%d", run.ID)) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { if httpErr.StatusCode == http.StatusConflict { err = fmt.Errorf("cannot delete a workflow run that is completed") diff --git a/pkg/cmd/run/rerun/rerun.go b/pkg/cmd/run/rerun/rerun.go index 8f8b79a2edf..93f6262fae7 100644 --- a/pkg/cmd/run/rerun/rerun.go +++ b/pkg/cmd/run/rerun/rerun.go @@ -12,6 +12,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/run/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -205,7 +206,7 @@ func rerunRun(client *api.Client, repo ghrepo.Interface, run *shared.Run, onlyFa err = client.REST(repo.RepoHost(), "POST", path.String(), body, nil) if err != nil { - var httpError api.HTTPError + var httpError *githubrest.ErrorResponse if errors.As(err, &httpError) && httpError.StatusCode == 403 { return fmt.Errorf("run %d cannot be rerun; %s", run.ID, httpError.Message) } @@ -227,7 +228,7 @@ func rerunJob(client *api.Client, repo ghrepo.Interface, job *shared.Job, debug err = client.REST(repo.RepoHost(), "POST", path.String(), body, nil) if err != nil { - var httpError api.HTTPError + var httpError *githubrest.ErrorResponse if errors.As(err, &httpError) && httpError.StatusCode == 403 { return fmt.Errorf("job %d cannot be rerun", job.ID) } diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index 6526292e24c..c7b0891945e 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/iostreams" @@ -289,7 +290,7 @@ func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annot err = client.REST(repo.RepoHost(), "GET", path.String(), nil, &result) if err != nil { - var httpError api.HTTPError + var httpError *githubrest.ErrorResponse if !errors.As(err, &httpError) { return nil, err } @@ -468,7 +469,7 @@ func preloadWorkflowNames(client *api.Client, repo ghrepo.Interface, runs []Run) // to an empty string. // Deciding to put this here instead of in GetWorkflow to allow // the caller to decide what a 404 means. - if httpErr, ok := err.(api.HTTPError); ok && httpErr.StatusCode == 404 { + if httpErr, ok := err.(*githubrest.ErrorResponse); ok && httpErr.StatusCode == 404 { workflowMap[run.WorkflowID] = "" continue } diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 1a5353d59eb..3ca2c9421f5 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -16,6 +16,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/gh/ghtelemetry" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/skills/discovery" @@ -711,7 +712,7 @@ func couldBeOwner(s string) bool { // - HTTP 403 with x-ratelimit-remaining: 0 (primary rate limit) // - HTTP 403 with a retry-after header (secondary rate limit) func isRateLimitError(err error) bool { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if !errors.As(err, &httpErr) { return false } diff --git a/pkg/cmd/ssh-key/add/add_test.go b/pkg/cmd/ssh-key/add/add_test.go index d167d2fe6e9..7b540f6ca16 100644 --- a/pkg/cmd/ssh-key/add/add_test.go +++ b/pkg/cmd/ssh-key/add/add_test.go @@ -5,9 +5,9 @@ import ( "strings" "testing" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -173,7 +173,7 @@ func TestSSHKeyUploadHTTPError(t *testing.T) { ) assert.False(t, uploaded) - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, http.StatusUnprocessableEntity, httpErr.StatusCode) assert.Contains(t, err.Error(), "HTTP 422") diff --git a/pkg/cmd/ssh-key/delete/delete_test.go b/pkg/cmd/ssh-key/delete/delete_test.go index 8ec39f68449..2b7eabcb44b 100644 --- a/pkg/cmd/ssh-key/delete/delete_test.go +++ b/pkg/cmd/ssh-key/delete/delete_test.go @@ -5,9 +5,9 @@ import ( "net/http" "testing" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -222,7 +222,7 @@ func TestDeleteSSHKeyHTTPError(t *testing.T) { err := deleteSSHKey(&http.Client{Transport: reg}, "github.com", "1234") - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) assert.Contains(t, err.Error(), "HTTP 404") @@ -239,7 +239,7 @@ func TestGetSSHKeyHTTPError(t *testing.T) { key, err := getSSHKey(&http.Client{Transport: reg}, "github.com", "1234") assert.Nil(t, key) - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) assert.Contains(t, err.Error(), "HTTP 404") diff --git a/pkg/cmd/ssh-key/list/list.go b/pkg/cmd/ssh-key/list/list.go index b92f4274c78..9d6b474d68d 100644 --- a/pkg/cmd/ssh-key/list/list.go +++ b/pkg/cmd/ssh-key/list/list.go @@ -8,8 +8,8 @@ import ( "strconv" "time" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/ssh-key/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -120,7 +120,7 @@ func truncateMiddle(maxWidth int, t string) string { func printError(w io.Writer, err error) { fmt.Fprintln(w, "warning: ", err) - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { if msg := httpErr.ScopesSuggestion(); msg != "" { fmt.Fprintln(w, msg) diff --git a/pkg/cmd/ssh-key/shared/user_keys_test.go b/pkg/cmd/ssh-key/shared/user_keys_test.go index 3a3142a8721..f64c0b67ab1 100644 --- a/pkg/cmd/ssh-key/shared/user_keys_test.go +++ b/pkg/cmd/ssh-key/shared/user_keys_test.go @@ -4,7 +4,7 @@ import ( "net/http" "testing" - "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,7 +21,7 @@ func TestUserKeysHTTPError(t *testing.T) { keys, err := UserKeys(&http.Client{Transport: reg}, "github.com", "") assert.Nil(t, keys) - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) assert.Contains(t, err.Error(), "HTTP 404") diff --git a/pkg/cmd/status/status.go b/pkg/cmd/status/status.go index 6dbd4199986..12c3bc385ae 100644 --- a/pkg/cmd/status/status.go +++ b/pkg/cmd/status/status.go @@ -15,6 +15,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/charmbracelet/lipgloss" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/factory" @@ -286,7 +287,7 @@ func (s *StatusGetter) LoadNotifications() error { actual, err := s.ActualMention(safeurl.NewImmutableSafeURL(n.Subject.LatestCommentURL)) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse httpStatusCode := -1 if errors.As(err, &httpErr) { @@ -345,7 +346,7 @@ func (s *StatusGetter) LoadNotifications() error { var resp []Notification next, err := c.RESTWithNext(s.hostname(), "GET", p.String(), nil, &resp) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if !errors.As(err, &httpErr) || httpErr.StatusCode != 404 { return fmt.Errorf("could not get notifications: %w", err) } @@ -552,7 +553,7 @@ func (s *StatusGetter) LoadEvents() error { for pages < 2 { next, err := c.RESTWithNext(s.hostname(), "GET", p.String(), nil, &resp) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if !errors.As(err, &httpErr) || httpErr.StatusCode != 404 { return fmt.Errorf("could not get events: %w", err) } diff --git a/pkg/cmd/variable/get/get.go b/pkg/cmd/variable/get/get.go index 6247715e2f9..60c9cbe0659 100644 --- a/pkg/cmd/variable/get/get.go +++ b/pkg/cmd/variable/get/get.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/variable/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -117,7 +118,7 @@ func getRun(opts *GetOptions) error { var variable shared.Variable if err = client.REST(host, "GET", path.String(), nil, &variable); err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { return fmt.Errorf("variable %s was not found", opts.VariableName) } diff --git a/pkg/cmd/variable/set/http.go b/pkg/cmd/variable/set/http.go index 2a1f581c676..c45a25135f6 100644 --- a/pkg/cmd/variable/set/http.go +++ b/pkg/cmd/variable/set/http.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/variable/shared" ) @@ -44,7 +45,7 @@ type setResult struct { func setVariable(client *api.Client, host string, opts setOptions) setResult { var err error - var postErr api.HTTPError + var postErr *githubrest.ErrorResponse result := setResult{Operation: createdOperation, Key: opts.Key} switch opts.Entity { case shared.Organization: diff --git a/pkg/cmd/workflow/shared/shared.go b/pkg/cmd/workflow/shared/shared.go index 2cb6b91ff94..081773b8bab 100644 --- a/pkg/cmd/workflow/shared/shared.go +++ b/pkg/cmd/workflow/shared/shared.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -136,7 +137,7 @@ func FindWorkflow(client *api.Client, repo ghrepo.Interface, workflowSelector st if _, err := strconv.Atoi(workflowSelector); err == nil || isWorkflowFile(workflowSelector) { workflow, err := getWorkflowByID(client, repo, workflowSelector) if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { if httpErr.StatusCode == 404 { httpErr.Message = fmt.Sprintf("workflow %s not found on the default branch", workflowSelector) @@ -205,7 +206,7 @@ func ResolveWorkflow(p iprompter, io *iostreams.IOStreams, client *api.Client, r } if err != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { err = errors.New("no workflows are enabled") } diff --git a/pkg/cmd/workflow/shared/shared_test.go b/pkg/cmd/workflow/shared/shared_test.go index cc9017d3643..644fff256e8 100644 --- a/pkg/cmd/workflow/shared/shared_test.go +++ b/pkg/cmd/workflow/shared/shared_test.go @@ -10,11 +10,10 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - ghAPI "github.com/cli/go-gh/v2/pkg/api" ) func TestFindWorkflow(t *testing.T) { @@ -30,7 +29,7 @@ func TestFindWorkflow(t *testing.T) { httpStubs func(*httpmock.Registry) states []WorkflowState expectedWorkflow Workflow - expectedHTTPError *api.HTTPError + expectedHTTPError *githubrest.ErrorResponse expectedError error }{ { @@ -85,12 +84,10 @@ func TestFindWorkflow(t *testing.T) { httpmock.StatusJSONResponse(404, Workflow{}), ) }, - expectedHTTPError: &api.HTTPError{ - HTTPError: &ghAPI.HTTPError{ - Message: "workflow nonexistentWorkflow.yml not found on the default branch", - StatusCode: 404, - RequestURL: badRequestURL, - }, + expectedHTTPError: &githubrest.ErrorResponse{ + Message: "workflow nonexistentWorkflow.yml not found on the default branch", + StatusCode: 404, + RequestURL: badRequestURL, }, }, { @@ -103,16 +100,14 @@ func TestFindWorkflow(t *testing.T) { httpmock.StatusStringResponse(500, "server error"), ) }, - expectedHTTPError: &api.HTTPError{ - HTTPError: &ghAPI.HTTPError{ - Errors: []ghAPI.HTTPErrorItem{ - { - Message: "server error", - }, + expectedHTTPError: &githubrest.ErrorResponse{ + Errors: []githubrest.ErrorItem{ + { + Message: "server error", }, - StatusCode: 500, - RequestURL: badRequestURL, }, + StatusCode: 500, + RequestURL: badRequestURL, }, }, { @@ -154,7 +149,7 @@ func TestFindWorkflow(t *testing.T) { require.Error(t, err) assert.Equal(t, tt.expectedError, err) } else if tt.expectedHTTPError != nil { - var httpErr api.HTTPError + var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) assert.Equal(t, tt.expectedHTTPError.Error(), httpErr.Error()) } else { diff --git a/pkg/cmd/workflow/view/view.go b/pkg/cmd/workflow/view/view.go index 2e550f4963f..2713c727543 100644 --- a/pkg/cmd/workflow/view/view.go +++ b/pkg/cmd/workflow/view/view.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" runShared "github.com/cli/cli/v2/pkg/cmd/run/shared" @@ -159,7 +160,7 @@ func runView(opts *ViewOptions) error { func viewWorkflowContent(opts *ViewOptions, client *api.Client, repo ghrepo.Interface, workflow *shared.Workflow, ref string) error { yamlBytes, err := shared.GetWorkflowContent(client, repo, *workflow, ref) if err != nil { - if s, ok := err.(api.HTTPError); ok && s.StatusCode == 404 { + if s, ok := err.(*githubrest.ErrorResponse); ok && s.StatusCode == 404 { if ref != "" { return fmt.Errorf("could not find workflow file %s on %s, try specifying a different ref", workflow.Base(), ref) } From 74a456536ad604d115a5d438a645f1707102a9d1 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 14:29:29 +0200 Subject: [PATCH 07/21] Route gh api through githubrest The client is built once per invocation from the request's own origin when the path is absolute, so the token stays bound to where the request goes rather than being chosen per request by a transport. gh api keeps building its own transport, because --cache and --verbose are per-invocation transport settings no other command has. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- pkg/cmd/api/api.go | 78 ++++++++++++++++++++++++++++------------ pkg/cmd/api/api_test.go | 76 ++++++++++++++++++++------------------- pkg/cmd/api/http.go | 30 +++++++++------- pkg/cmd/api/http_test.go | 23 ++++++------ 4 files changed, 123 insertions(+), 84 deletions(-) diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 5b85f987ca4..f9f5801107d 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -2,11 +2,13 @@ package api import ( "bytes" + "context" "encoding/json" "errors" "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -16,14 +18,15 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/jsoncolor" + ghauth "github.com/cli/go-gh/v2/pkg/auth" "github.com/cli/go-gh/v2/pkg/jq" "github.com/cli/go-gh/v2/pkg/template" "github.com/spf13/cobra" @@ -39,7 +42,7 @@ type ApiOptions struct { BaseRepo func() (ghrepo.Interface, error) Branch func() (string, error) Config func() (gh.Config, error) - HttpClient func() (*http.Client, error) + GitHubREST func(apiBaseURL, tokenHost string) (*githubrest.Client, error) IO *iostreams.IOStreams Hostname string @@ -281,7 +284,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command if runF != nil { return runF(&opts) } - return apiRun(&opts) + return apiRun(c.Context(), &opts) }, } @@ -304,7 +307,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command return cmd } -func apiRun(opts *ApiOptions) error { +func apiRun(ctx context.Context, opts *ApiOptions) error { params, err := parseFields(opts) if err != nil { return err @@ -388,36 +391,63 @@ func apiRun(opts *ApiOptions) error { return err } - if opts.HttpClient == nil { - opts.HttpClient = func() (*http.Client, error) { + host, _ := cfg.Authentication().DefaultHost() + if opts.Hostname != "" { + host = opts.Hostname + } + apiBaseURL := ghinstance.RESTPrefix(host) + + // An absolute URL names its own origin, and that origin decides both where + // the request goes and which token it may carry. Building the client from it + // keeps the token bound to the destination, which the old transport did per + // request by looking at the URL it was handed. It also means a plaintext + // local server stays reachable, since the client is not pinned to https by a + // base URL the request never uses. + if u, urlErr := url.Parse(requestPath); urlErr == nil && u.Scheme != "" && u.Host != "" { + host = ghauth.NormalizeHostname(u.Hostname()) + apiBaseURL = u.Scheme + "://" + u.Host + "/" + } + + if opts.GitHubREST == nil { + // gh api builds its own transport rather than taking the factory's, + // because --cache and --verbose are per-invocation transport settings + // that no other command has. + opts.GitHubREST = func(apiBaseURL, tokenHost string) (*githubrest.Client, error) { log := opts.IO.ErrOut if opts.Verbose { log = opts.IO.Out } - opts := api.HTTPClientOptions{ + httpClient, err := githubrest.NewHTTPClient(githubrest.HTTPClientOptions{ AppVersion: opts.AppVersion, InvokingAgent: opts.InvokingAgent, CacheTTL: opts.CacheTTL, - Config: cfg.Authentication(), EnableCache: opts.CacheTTL > 0, Log: log, LogColorize: opts.IO.ColorEnabled(), LogVerboseHTTP: opts.Verbose, + }) + if err != nil { + return nil, err + } + + auth := githubrest.WithoutToken() + if token, _ := cfg.Authentication().ActiveToken(tokenHost); token != "" { + auth = githubrest.WithToken(token) } - return api.NewHTTPClient(opts) + + var clientOpts []githubrest.ClientOption + if tokenHost == ghinstance.Default() { + clientOpts = append(clientOpts, githubrest.WithCredentialedHost(ghinstance.UploadHost(tokenHost))) + } + return githubrest.NewClient(apiBaseURL, httpClient, auth, clientOpts...) } } - httpClient, err := opts.HttpClient() + + restClient, err := opts.GitHubREST(apiBaseURL, host) if err != nil { return err } - host, _ := cfg.Authentication().DefaultHost() - - if opts.Hostname != "" { - host = opts.Hostname - } - tmpl := template.New(bodyWriter, opts.IO.TerminalWidth(), opts.IO.ColorEnabled()) err = tmpl.Parse(opts.Template) if err != nil { @@ -427,13 +457,17 @@ func apiRun(opts *ApiOptions) error { isFirstPage := true hasNextPage := true for hasNextPage { - resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders) - if err != nil { + resp, err := httpRequest(ctx, restClient, host, method, requestPath, requestBody, requestHeaders) + // A non-2xx is reported as an error but still carries the response, and + // printing what the server sent is the whole point of gh api, so only a + // failure with no response at all stops here. + var errResp *githubrest.ErrorResponse + if err != nil && !errors.As(err, &errResp) { return err } if !isGraphQL { - requestPath, hasNextPage = findNextPage(resp) + requestPath, hasNextPage = findNextPage(resp.Response) requestBody = nil // prevent repeating GET parameters } @@ -443,7 +477,7 @@ func apiRun(opts *ApiOptions) error { return err } - endCursor, err := processResponse(resp, opts, bodyWriter, headersWriter, tmpl, isFirstPage, !hasNextPage) + endCursor, err := processResponse(resp.Response, errResp, opts, bodyWriter, headersWriter, tmpl, isFirstPage, !hasNextPage) if err != nil { return err } @@ -470,7 +504,7 @@ func apiRun(opts *ApiOptions) error { var jsonContentTypeRE = regexp.MustCompile(`[/+]json(;|$)`) -func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersWriter io.Writer, template *template.Template, isFirstPage, isLastPage bool) (endCursor string, err error) { +func processResponse(resp *http.Response, errResp *githubrest.ErrorResponse, opts *ApiOptions, bodyWriter, headersWriter io.Writer, template *template.Template, isFirstPage, isLastPage bool) (endCursor string, err error) { if opts.ShowResponseHeaders { fmt.Fprintln(headersWriter, resp.Proto, resp.Status) printHeaders(headersWriter, resp.Header, opts.IO.ColorEnabled()) @@ -551,7 +585,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW } if serverError != "" { fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", serverError) - if msg := api.ScopesSuggestion(resp); msg != "" { + if msg := errResp.ScopesSuggestion(); msg != "" { fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", msg) } if u := factory.SSOURL(); u != "" { diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index 33a45543579..fdea7fbcc24 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -2,6 +2,7 @@ package api import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -19,6 +20,7 @@ import ( "github.com/cli/cli/v2/internal/gh" ghmock "github.com/cli/cli/v2/internal/gh/mock" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/go-gh/v2/pkg/template" @@ -741,16 +743,16 @@ func Test_apiRun(t *testing.T) { tt.options.IO = ios tt.options.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } - tt.options.HttpClient = func() (*http.Client, error) { + tt.options.GitHubREST = func(apiBaseURL, tokenHost string) (*githubrest.Client, error) { var tr roundTripper = func(req *http.Request) (*http.Response, error) { resp := tt.httpResponse resp.Request = req return resp, nil } - return &http.Client{Transport: tr}, nil + return githubrest.NewClient(apiBaseURL, &http.Client{Transport: tr}, githubrest.WithoutToken()) } - err := apiRun(&tt.options) + err := apiRun(context.Background(), &tt.options) if tt.errMsg != "" { if err == nil || err.Error() != tt.errMsg { t.Errorf("expected error %q, got %v", tt.errMsg, err) @@ -810,14 +812,14 @@ func Test_apiRun_paginationREST(t *testing.T) { options := ApiOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { + GitHubREST: func(apiBaseURL, tokenHost string) (*githubrest.Client, error) { var tr roundTripper = func(req *http.Request) (*http.Response, error) { resp := responses[requestCount] resp.Request = req requestCount++ return resp, nil } - return &http.Client{Transport: tr}, nil + return githubrest.NewClient(apiBaseURL, &http.Client{Transport: tr}, githubrest.WithoutToken()) }, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil @@ -830,7 +832,7 @@ func Test_apiRun_paginationREST(t *testing.T) { RawFields: []string{"per_page=50", "page=1"}, } - err := apiRun(&options) + err := apiRun(context.Background(), &options) assert.NoError(t, err) assert.Equal(t, `{"page":1}{"page":2}{"page":3}`, stdout.String(), "stdout") @@ -882,14 +884,14 @@ func Test_apiRun_arrayPaginationREST(t *testing.T) { options := ApiOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { + GitHubREST: func(apiBaseURL, tokenHost string) (*githubrest.Client, error) { var tr roundTripper = func(req *http.Request) (*http.Response, error) { resp := responses[requestCount] resp.Request = req requestCount++ return resp, nil } - return &http.Client{Transport: tr}, nil + return githubrest.NewClient(apiBaseURL, &http.Client{Transport: tr}, githubrest.WithoutToken()) }, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil @@ -902,7 +904,7 @@ func Test_apiRun_arrayPaginationREST(t *testing.T) { RawFields: []string{"per_page=50", "page=1"}, } - err := apiRun(&options) + err := apiRun(context.Background(), &options) assert.NoError(t, err) assert.Equal(t, `[{"item":1},{"item":2},{"item":3},{"item":4},{"item":5} ]`, stdout.String(), "stdout") @@ -954,14 +956,14 @@ func Test_apiRun_arrayPaginationREST_with_headers(t *testing.T) { options := ApiOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { + GitHubREST: func(apiBaseURL, tokenHost string) (*githubrest.Client, error) { var tr roundTripper = func(req *http.Request) (*http.Response, error) { resp := responses[requestCount] resp.Request = req requestCount++ return resp, nil } - return &http.Client{Transport: tr}, nil + return githubrest.NewClient(apiBaseURL, &http.Client{Transport: tr}, githubrest.WithoutToken()) }, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil @@ -975,7 +977,7 @@ func Test_apiRun_arrayPaginationREST_with_headers(t *testing.T) { ShowResponseHeaders: true, } - err := apiRun(&options) + err := apiRun(context.Background(), &options) assert.NoError(t, err) assert.Equal(t, "HTTP/1.1 200 OK\nContent-Type: application/json\r\nLink: ; rel=\"next\", ; rel=\"last\"\r\nX-Github-Request-Id: 1\r\n\r\n[{\"page\":1}]\nHTTP/1.1 200 OK\nContent-Type: application/json\r\nLink: ; rel=\"next\", ; rel=\"last\"\r\nX-Github-Request-Id: 2\r\n\r\n[{\"page\":2}]\nHTTP/1.1 200 OK\nContent-Type: application/json\r\nX-Github-Request-Id: 3\r\n\r\n[{\"page\":3}]", stdout.String(), "stdout") @@ -1023,14 +1025,14 @@ func Test_apiRun_paginationGraphQL(t *testing.T) { options := ApiOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { + GitHubREST: func(apiBaseURL, tokenHost string) (*githubrest.Client, error) { var tr roundTripper = func(req *http.Request) (*http.Response, error) { resp := responses[requestCount] resp.Request = req requestCount++ return resp, nil } - return &http.Client{Transport: tr}, nil + return githubrest.NewClient(apiBaseURL, &http.Client{Transport: tr}, githubrest.WithoutToken()) }, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil @@ -1042,7 +1044,7 @@ func Test_apiRun_paginationGraphQL(t *testing.T) { Paginate: true, } - err := apiRun(&options) + err := apiRun(context.Background(), &options) require.NoError(t, err) assert.Equal(t, heredoc.Doc(` @@ -1122,14 +1124,14 @@ func Test_apiRun_paginationGraphQL_slurp(t *testing.T) { options := ApiOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { + GitHubREST: func(apiBaseURL, tokenHost string) (*githubrest.Client, error) { var tr roundTripper = func(req *http.Request) (*http.Response, error) { resp := responses[requestCount] resp.Request = req requestCount++ return resp, nil } - return &http.Client{Transport: tr}, nil + return githubrest.NewClient(apiBaseURL, &http.Client{Transport: tr}, githubrest.WithoutToken()) }, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil @@ -1142,7 +1144,7 @@ func Test_apiRun_paginationGraphQL_slurp(t *testing.T) { Slurp: true, } - err := apiRun(&options) + err := apiRun(context.Background(), &options) require.NoError(t, err) assert.JSONEq(t, stdout.String(), `[ @@ -1234,14 +1236,14 @@ func Test_apiRun_paginated_template(t *testing.T) { options := ApiOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { + GitHubREST: func(apiBaseURL, tokenHost string) (*githubrest.Client, error) { var tr roundTripper = func(req *http.Request) (*http.Response, error) { resp := responses[requestCount] resp.Request = req requestCount++ return resp, nil } - return &http.Client{Transport: tr}, nil + return githubrest.NewClient(apiBaseURL, &http.Client{Transport: tr}, githubrest.WithoutToken()) }, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil @@ -1255,7 +1257,7 @@ func Test_apiRun_paginated_template(t *testing.T) { Template: `{{range .data.nodes}}{{tablerow .page .caption}}{{end}}`, } - err := apiRun(&options) + err := apiRun(context.Background(), &options) require.NoError(t, err) assert.Equal(t, heredoc.Doc(` @@ -1288,17 +1290,17 @@ func Test_apiRun_DELETE(t *testing.T) { ios, _, _, _ := iostreams.Test() var gotRequest *http.Request - err := apiRun(&ApiOptions{ + err := apiRun(context.Background(), &ApiOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { + GitHubREST: func(apiBaseURL, tokenHost string) (*githubrest.Client, error) { var tr roundTripper = func(req *http.Request) (*http.Response, error) { gotRequest = req return &http.Response{StatusCode: 204, Request: req}, nil } - return &http.Client{Transport: tr}, nil + return githubrest.NewClient(apiBaseURL, &http.Client{Transport: tr}, githubrest.WithoutToken()) }, MagicFields: []string(nil), RawFields: []string(nil), @@ -1317,12 +1319,12 @@ func Test_apiRun_DELETE(t *testing.T) { func Test_apiRun_HEAD(t *testing.T) { ios, _, _, _ := iostreams.Test() - err := apiRun(&ApiOptions{ + err := apiRun(context.Background(), &ApiOptions{ IO: ios, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { + GitHubREST: func(apiBaseURL, tokenHost string) (*githubrest.Client, error) { var tr roundTripper = func(req *http.Request) (*http.Response, error) { return &http.Response{ StatusCode: 422, @@ -1331,7 +1333,7 @@ func Test_apiRun_HEAD(t *testing.T) { "Content-Type": {"application/json"}, }}, nil } - return &http.Client{Transport: tr}, nil + return githubrest.NewClient(apiBaseURL, &http.Client{Transport: tr}, githubrest.WithoutToken()) }, MagicFields: []string(nil), RawFields: []string(nil), @@ -1393,7 +1395,7 @@ func Test_apiRun_inputFile(t *testing.T) { RawFields: []string{"a=b", "c=d"}, IO: ios, - HttpClient: func() (*http.Client, error) { + GitHubREST: func(apiBaseURL, tokenHost string) (*githubrest.Client, error) { var tr roundTripper = func(req *http.Request) (*http.Response, error) { var err error if bodyBytes, err = io.ReadAll(req.Body); err != nil { @@ -1402,14 +1404,14 @@ func Test_apiRun_inputFile(t *testing.T) { resp.Request = req return resp, nil } - return &http.Client{Transport: tr}, nil + return githubrest.NewClient(apiBaseURL, &http.Client{Transport: tr}, githubrest.WithoutToken()) }, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, } - err := apiRun(&options) + err := apiRun(context.Background(), &options) if err != nil { t.Errorf("got error %v", err) } @@ -1459,8 +1461,8 @@ func Test_apiRun_cache(t *testing.T) { } // When we run the API behaviour twice - require.NoError(t, apiRun(&options)) - require.NoError(t, apiRun(&options)) + require.NoError(t, apiRun(context.Background(), &options)) + require.NoError(t, apiRun(context.Background(), &options)) // We only get one request to the http server because it uses the cached response assert.Equal(t, 1, requestCount) @@ -1493,7 +1495,7 @@ func Test_apiRun_invokingAgent(t *testing.T) { RequestPath: s.URL, } - require.NoError(t, apiRun(&options)) + require.NoError(t, apiRun(context.Background(), &options)) assert.Contains(t, receivedUA, "GitHub CLI 1.2.3") assert.Contains(t, receivedUA, "Agent/copilot-cli") } @@ -1758,7 +1760,7 @@ func Test_processResponse_template(t *testing.T) { tmpl := template.New(ios.Out, ios.TerminalWidth(), ios.ColorEnabled()) err := tmpl.Parse(opts.Template) require.NoError(t, err) - _, err = processResponse(&resp, &opts, ios.Out, io.Discard, tmpl, true, true) + _, err = processResponse(&resp, nil, &opts, ios.Out, io.Discard, tmpl, true, true) require.NoError(t, err) err = tmpl.Flush() require.NoError(t, err) @@ -1889,7 +1891,7 @@ func Test_apiRun_acceptHeader(t *testing.T) { } var gotReq *http.Request - tt.options.HttpClient = func() (*http.Client, error) { + tt.options.GitHubREST = func(apiBaseURL, tokenHost string) (*githubrest.Client, error) { var tr roundTripper = func(req *http.Request) (*http.Response, error) { gotReq = req resp := &http.Response{ @@ -1899,10 +1901,10 @@ func Test_apiRun_acceptHeader(t *testing.T) { } return resp, nil } - return &http.Client{Transport: tr}, nil + return githubrest.NewClient(apiBaseURL, &http.Client{Transport: tr}, githubrest.WithoutToken()) } - assert.NoError(t, apiRun(&tt.options)) + assert.NoError(t, apiRun(context.Background(), &tt.options)) assert.Equal(t, tt.wantAcceptHeader, gotReq.Header.Get("Accept")) }) } diff --git a/pkg/cmd/api/http.go b/pkg/cmd/api/http.go index 337a07b7d0a..1c79b10a538 100644 --- a/pkg/cmd/api/http.go +++ b/pkg/cmd/api/http.go @@ -2,28 +2,31 @@ package api import ( "bytes" + "context" "encoding/json" "fmt" "io" - "net/http" "net/url" "strconv" "strings" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/githubrest" ) -func httpRequest(client *http.Client, hostname string, method string, p string, params interface{}, headers []string) (*http.Response, error) { +// httpRequest issues the request the user described. +// +// The path is passed to the client verbatim, exactly as it arrived from the +// user: gh api is the one command whose whole job is to send what it was asked +// to send, so it is deliberately not routed through safeurl and not escaped. +// The client resolves a relative path against its API base URL, which is how +// hostname stops being a per-request argument; only the GraphQL endpoint still +// needs it, because it does not sit under the REST prefix. +func httpRequest(ctx context.Context, client *githubrest.Client, hostname string, method string, p string, params interface{}, headers []string) (*githubrest.Response, error) { isGraphQL := p == "graphql" - var requestURL string - if strings.Contains(p, "://") { - requestURL = p - } else if isGraphQL { + requestURL := p + if isGraphQL && !strings.Contains(p, "://") { requestURL = ghinstance.GraphQLEndpoint(hostname) - } else { - // Note that the gh api command takes the path verbatim from the user, so we - // intentionally do not route it through safeurl and do not escape it here. - requestURL = ghinstance.RESTPrefix(hostname) + strings.TrimPrefix(p, "/") } var body io.Reader @@ -52,7 +55,7 @@ func httpRequest(client *http.Client, hostname string, method string, p string, return nil, fmt.Errorf("unrecognized parameters type: %v", params) } - req, err := http.NewRequest(strings.ToUpper(method), requestURL, body) + req, err := client.NewRequest(ctx, strings.ToUpper(method), requestURL, body) if err != nil { return nil, err } @@ -76,11 +79,14 @@ func httpRequest(client *http.Client, hostname string, method string, p string, if bodyIsJSON && req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json; charset=utf-8") } + // Set rather than left to the transport's default of application/vnd.github+json, + // because gh api has always accepted anything and users rely on that for + // diffs, patches and raw file content. if req.Header.Get("Accept") == "" { req.Header.Set("Accept", "*/*") } - return client.Do(req) + return client.Send(req) } func groupGraphQLVariables(params map[string]interface{}) map[string]interface{} { diff --git a/pkg/cmd/api/http_test.go b/pkg/cmd/api/http_test.go index 2778ea38c7b..2da496a2ab9 100644 --- a/pkg/cmd/api/http_test.go +++ b/pkg/cmd/api/http_test.go @@ -2,10 +2,13 @@ package api import ( "bytes" + "context" "io" "net/http" "testing" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/githubrest" "github.com/stretchr/testify/assert" ) @@ -87,12 +90,11 @@ func (f roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { func Test_httpRequest(t *testing.T) { var tr roundTripper = func(req *http.Request) (*http.Response, error) { - return &http.Response{Request: req}, nil + return &http.Response{StatusCode: http.StatusOK, Request: req}, nil } httpClient := http.Client{Transport: tr} type args struct { - client *http.Client host string method string p string @@ -114,7 +116,6 @@ func Test_httpRequest(t *testing.T) { { name: "simple GET", args: args{ - client: &httpClient, host: "github.com", method: "GET", p: "repos/octocat/spoon-knife", @@ -132,7 +133,6 @@ func Test_httpRequest(t *testing.T) { { name: "GET with accept header", args: args{ - client: &httpClient, host: "github.com", method: "GET", p: "repos/octocat/spoon-knife", @@ -150,7 +150,6 @@ func Test_httpRequest(t *testing.T) { { name: "lowercase HTTP method", args: args{ - client: &httpClient, host: "github.com", method: "get", p: "repos/octocat/spoon-knife", @@ -168,7 +167,6 @@ func Test_httpRequest(t *testing.T) { { name: "GET with leading slash", args: args{ - client: &httpClient, host: "github.com", method: "GET", p: "/repos/octocat/spoon-knife", @@ -186,7 +184,6 @@ func Test_httpRequest(t *testing.T) { { name: "Enterprise REST", args: args{ - client: &httpClient, host: "example.org", method: "GET", p: "repos/octocat/spoon-knife", @@ -204,7 +201,6 @@ func Test_httpRequest(t *testing.T) { { name: "GET with params", args: args{ - client: &httpClient, host: "github.com", method: "GET", p: "repos/octocat/spoon-knife", @@ -224,7 +220,6 @@ func Test_httpRequest(t *testing.T) { { name: "POST with params", args: args{ - client: &httpClient, host: "github.com", method: "POST", p: "repos", @@ -244,7 +239,6 @@ func Test_httpRequest(t *testing.T) { { name: "POST GraphQL", args: args{ - client: &httpClient, host: "github.com", method: "POST", p: "graphql", @@ -264,7 +258,6 @@ func Test_httpRequest(t *testing.T) { { name: "Enterprise GraphQL", args: args{ - client: &httpClient, host: "example.org", method: "POST", p: "graphql", @@ -282,7 +275,6 @@ func Test_httpRequest(t *testing.T) { { name: "POST with body and type", args: args{ - client: &httpClient, host: "github.com", method: "POST", p: "repos", @@ -303,7 +295,12 @@ func Test_httpRequest(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := httpRequest(tt.args.client, tt.args.host, tt.args.method, tt.args.p, tt.args.params, tt.args.headers) + client, err := githubrest.NewClient(ghinstance.RESTPrefix(tt.args.host), &httpClient, githubrest.WithoutToken()) + if err != nil { + t.Fatal(err) + } + + got, err := httpRequest(context.Background(), client, tt.args.host, tt.args.method, tt.args.p, tt.args.params, tt.args.headers) if (err != nil) != tt.wantErr { t.Errorf("httpRequest() error = %v, wantErr %v", err, tt.wantErr) return From 40b5e36f04a553613088508f289bc89746c0098d Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 14:37:04 +0200 Subject: [PATCH 08/21] Route gh release through githubrest Exercises the two bespoke pieces of the client surface: NewUploadRequest for release assets, and a per-client redirect policy for the Codeload archive path rewrite, which cannot be a request option. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- pkg/cmd/release/create/create.go | 30 +++-- pkg/cmd/release/create/create_test.go | 28 +++-- pkg/cmd/release/create/http.go | 103 +++++++++--------- pkg/cmd/release/create/http_test.go | 22 ++-- pkg/cmd/release/delete-asset/delete_asset.go | 33 +++--- .../release/delete-asset/delete_asset_test.go | 4 +- pkg/cmd/release/delete/delete.go | 51 ++++----- pkg/cmd/release/delete/delete_test.go | 4 +- pkg/cmd/release/download/download.go | 77 +++++++------ pkg/cmd/release/download/download_test.go | 10 +- pkg/cmd/release/edit/edit.go | 16 ++- pkg/cmd/release/edit/edit_test.go | 4 +- pkg/cmd/release/edit/http.go | 30 ++--- pkg/cmd/release/shared/fetch.go | 69 +++++------- pkg/cmd/release/shared/fetch_test.go | 8 +- pkg/cmd/release/shared/upload.go | 68 +++--------- pkg/cmd/release/shared/upload_test.go | 14 ++- pkg/cmd/release/upload/upload.go | 16 ++- pkg/cmd/release/verify-asset/verify_asset.go | 12 +- .../release/verify-asset/verify_asset_test.go | 16 +++ pkg/cmd/release/verify/verify.go | 12 +- pkg/cmd/release/verify/verify_test.go | 13 +++ pkg/cmd/release/view/view.go | 12 +- pkg/cmd/release/view/view_test.go | 1 + 24 files changed, 354 insertions(+), 299 deletions(-) diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 79d92bdf153..abf1fd87922 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -13,6 +13,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/release/shared" @@ -32,6 +33,7 @@ type CreateOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) GitClient *git.Client BaseRepo func() (ghrepo.Interface, error) Edit func(string, string, string, io.Reader, io.Writer, io.Writer) (string, error) @@ -68,6 +70,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, GitClient: f.GitClient, Config: f.Config, Prompter: f.Prompter, @@ -202,7 +205,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co if runF != nil { return runF(opts) } - return createRun(opts) + return createRun(cmd.Context(), opts) }, } @@ -225,7 +228,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return cmd } -func createRun(opts *CreateOptions) error { +func createRun(ctx context.Context, opts *CreateOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -236,8 +239,13 @@ func createRun(opts *CreateOptions) error { return err } + client, err := opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return err + } + if opts.FailOnNoCommits { - isNew, err := isNewRelease(httpClient, baseRepo) + isNew, err := isNewRelease(ctx, client, baseRepo) if err != nil { return fmt.Errorf("failed to check whether there were new commits since last release: %v", err) } @@ -248,7 +256,7 @@ func createRun(opts *CreateOptions) error { var existingTag bool if opts.TagName == "" { - tags, err := getTags(httpClient, baseRepo, 5) + tags, err := getTags(ctx, client, baseRepo, 5) if err != nil { return err } @@ -329,7 +337,7 @@ func createRun(opts *CreateOptions) error { var generatedNotes *releaseNotes var generatedChangelog string - generatedNotes, err = generateReleaseNotes(httpClient, baseRepo, opts.TagName, opts.Target, opts.NotesStartTag) + generatedNotes, err = generateReleaseNotes(ctx, client, baseRepo, opts.TagName, opts.Target, opts.NotesStartTag) if err != nil && !errors.Is(err, notImplementedError) { return err } @@ -468,7 +476,7 @@ func createRun(opts *CreateOptions) error { } if opts.GenerateNotes { if opts.NotesStartTag != "" { - generatedNotes, err := generateReleaseNotes(httpClient, baseRepo, opts.TagName, opts.Target, opts.NotesStartTag) + generatedNotes, err := generateReleaseNotes(ctx, client, baseRepo, opts.TagName, opts.Target, opts.NotesStartTag) if err != nil && !errors.Is(err, notImplementedError) { return err } @@ -500,7 +508,7 @@ func createRun(opts *CreateOptions) error { if hasAssets && !opts.Draft { // Check for an existing release if opts.TagName != "" { - if ok, err := publishedReleaseExists(httpClient, baseRepo, opts.TagName); err != nil { + if ok, err := publishedReleaseExists(ctx, client, baseRepo, opts.TagName); err != nil { return fmt.Errorf("error checking for existing release: %w", err) } else if ok { return fmt.Errorf("a release with the same tag name already exists: %s", opts.TagName) @@ -511,7 +519,7 @@ func createRun(opts *CreateOptions) error { params["draft"] = true } - newRelease, err := createRelease(httpClient, baseRepo, params) + newRelease, err := createRelease(ctx, client, baseRepo, params) var errMissingRequiredWorkflowScope *errMissingRequiredWorkflowScope if errors.As(err, &errMissingRequiredWorkflowScope) { @@ -535,7 +543,7 @@ func createRun(opts *CreateOptions) error { if !draftWhileUploading { return err } - if cleanupErr := deleteRelease(httpClient, baseRepo.RepoHost(), safeurl.NewImmutableSafeURL(newRelease.APIURL)); cleanupErr != nil { + if cleanupErr := deleteRelease(ctx, client, safeurl.NewImmutableSafeURL(newRelease.APIURL)); cleanupErr != nil { return fmt.Errorf("%w\ncleaning up draft failed: %v", err, cleanupErr) } return err @@ -548,14 +556,14 @@ func createRun(opts *CreateOptions) error { } opts.IO.StartProgressIndicator() - err = shared.ConcurrentUpload(httpClient, safeurl.NewImmutableSafeURL(uploadURL), opts.Concurrency, opts.Assets) + err = shared.ConcurrentUpload(ctx, client, safeurl.NewImmutableSafeURL(uploadURL), opts.Concurrency, opts.Assets) opts.IO.StopProgressIndicator() if err != nil { return cleanupDraftRelease(err) } if draftWhileUploading { - rel, err := publishRelease(httpClient, baseRepo.RepoHost(), safeurl.NewImmutableSafeURL(newRelease.APIURL), opts.DiscussionCategory, opts.IsLatest) + rel, err := publishRelease(ctx, client, safeurl.NewImmutableSafeURL(newRelease.APIURL), opts.DiscussionCategory, opts.IsLatest) if err != nil { return cleanupDraftRelease(err) } diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index eabb936758a..4e37c70dd80 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -2,6 +2,7 @@ package create import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -810,7 +811,8 @@ func Test_createRun(t *testing.T) { Target: "", Assets: []*shared.AssetForUpload{ { - Name: "ball.tgz", + Name: "ball.tgz", + MIMEType: "application/x-gtar", Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewBufferString(`TARBALL`)), nil }, @@ -869,7 +871,8 @@ func Test_createRun(t *testing.T) { Target: "", Assets: []*shared.AssetForUpload{ { - Name: "ball.tgz", + Name: "ball.tgz", + MIMEType: "application/x-gtar", Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewBufferString(`TARBALL`)), nil }, @@ -929,7 +932,8 @@ func Test_createRun(t *testing.T) { Target: "", Assets: []*shared.AssetForUpload{ { - Name: "ball.tgz", + Name: "ball.tgz", + MIMEType: "application/x-gtar", Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewBufferString(`TARBALL`)), nil }, @@ -957,7 +961,8 @@ func Test_createRun(t *testing.T) { Target: "", Assets: []*shared.AssetForUpload{ { - Name: "ball.tgz", + Name: "ball.tgz", + MIMEType: "application/x-gtar", Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewBufferString(`TARBALL`)), nil }, @@ -992,7 +997,8 @@ func Test_createRun(t *testing.T) { Target: "", Assets: []*shared.AssetForUpload{ { - Name: "ball.tgz", + Name: "ball.tgz", + MIMEType: "application/x-gtar", Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewBufferString(`TARBALL`)), nil }, @@ -1028,7 +1034,8 @@ func Test_createRun(t *testing.T) { Target: "", Assets: []*shared.AssetForUpload{ { - Name: "ball.tgz", + Name: "ball.tgz", + MIMEType: "application/x-gtar", Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewBufferString(`TARBALL`)), nil }, @@ -1056,7 +1063,8 @@ func Test_createRun(t *testing.T) { Target: "", Assets: []*shared.AssetForUpload{ { - Name: "ball.tgz", + Name: "ball.tgz", + MIMEType: "application/x-gtar", Open: func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewBufferString(`TARBALL`)), nil }, @@ -1279,6 +1287,7 @@ func Test_createRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: fakeHTTP}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(fakeHTTP) tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") } @@ -1291,7 +1300,7 @@ func Test_createRun(t *testing.T) { tt.runStubs(rs) } - err := createRun(&tt.opts) + err := createRun(context.Background(), &tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) } else { @@ -1847,6 +1856,7 @@ func Test_createRun_interactive(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") @@ -1874,7 +1884,7 @@ func Test_createRun_interactive(t *testing.T) { tt.runStubs(rs) } - err := createRun(tt.opts) + err := createRun(context.Background(), tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) diff --git a/pkg/cmd/release/create/http.go b/pkg/cmd/release/create/http.go index 86f37087d4f..d68ea408d2e 100644 --- a/pkg/cmd/release/create/http.go +++ b/pkg/cmd/release/create/http.go @@ -59,22 +59,24 @@ func remoteTagExists(httpClient *http.Client, repo ghrepo.Interface, tagName str return query.Repository.Ref.ID != "", err } -func getTags(httpClient *http.Client, repo ghrepo.Interface, limit int) ([]tag, error) { +func getTags(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, limit int) ([]tag, error) { u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "tags") if err != nil { return nil, err } u.SetQuery("per_page", strconv.Itoa(limit)) + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + var tags []tag - // 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, u.String(), nil, &tags) + _, err = client.Do(req, &tags) return tags, err } -func generateReleaseNotes(httpClient *http.Client, repo ghrepo.Interface, tagName, target, previousTagName string) (*releaseNotes, error) { +func generateReleaseNotes(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, tagName, target, previousTagName string) (*releaseNotes, error) { params := map[string]interface{}{ "tag_name": tagName, } @@ -95,12 +97,13 @@ func generateReleaseNotes(httpClient *http.Client, repo ghrepo.Interface, tagNam return nil, err } - var rn releaseNotes - // 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.MethodPost, path.String(), bytes.NewBuffer(bodyBytes), &rn) + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), bytes.NewBuffer(bodyBytes)) if err != nil { + return nil, err + } + + var rn releaseNotes + if _, err := client.Do(req, &rn); err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { return nil, notImplementedError @@ -110,36 +113,30 @@ func generateReleaseNotes(httpClient *http.Client, repo ghrepo.Interface, tagNam return &rn, nil } -func publishedReleaseExists(httpClient *http.Client, repo ghrepo.Interface, tagName string) (bool, error) { +// publishedReleaseExists asks with HEAD, so there is no body to decode; a nil +// decode target says exactly that. +func publishedReleaseExists(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, tagName string) (bool, error) { url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "releases", "tags", tagName) if err != nil { return false, err } - req, err := http.NewRequest("HEAD", url.String(), nil) + req, err := client.NewRequest(ctx, http.MethodHead, url.String(), nil) if err != nil { return false, err } - // TODO(api-client-rollout) - // This has been deferred from moving to api.Client due to HEAD responses having no body while REST decodes non-204/205 2xx responses as JSON. - resp, err := httpClient.Do(req) - if err != nil { + if _, err := client.Do(req, nil); err != nil { + var errResp *githubrest.ErrorResponse + if errors.As(err, &errResp) && errResp.StatusCode == http.StatusNotFound { + return false, nil + } return false, err } - if resp.Body != nil { - defer resp.Body.Close() - } - if resp.StatusCode == 200 { - return true, nil - } else if resp.StatusCode == 404 { - return false, nil - } else { - return false, api.HandleHTTPError(resp) - } + return true, nil } -func createRelease(httpClient *http.Client, repo ghrepo.Interface, params map[string]interface{}) (*shared.Release, error) { +func createRelease(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, params map[string]interface{}) (*shared.Release, error) { bodyBytes, err := json.Marshal(params) if err != nil { return nil, err @@ -161,12 +158,13 @@ func createRelease(httpClient *http.Client, repo ghrepo.Interface, params map[st // beyond returning a 404. // // https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps#available-scopes - var newRelease shared.Release - // 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.MethodPost, path.String(), bytes.NewBuffer(bodyBytes), &newRelease) + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), bytes.NewBuffer(bodyBytes)) if err != nil { + return nil, err + } + + var newRelease shared.Release + if _, err := client.Do(req, &newRelease); err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound && @@ -181,7 +179,7 @@ func createRelease(httpClient *http.Client, repo ghrepo.Interface, params map[st return &newRelease, nil } -func publishRelease(httpClient *http.Client, host string, releaseURL safeurl.SafeURL, discussionCategory string, isLatest *bool) (*shared.Release, error) { +func publishRelease(ctx context.Context, client *githubrest.Client, releaseURL safeurl.SafeURL, discussionCategory string, isLatest *bool) (*shared.Release, error) { params := map[string]interface{}{"draft": false} if discussionCategory != "" { params["discussion_category_name"] = discussionCategory @@ -196,22 +194,26 @@ func publishRelease(httpClient *http.Client, host string, releaseURL safeurl.Saf return nil, err } - var release shared.Release - // 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(host, http.MethodPatch, releaseURL.String(), bytes.NewBuffer(bodyBytes), &release) + req, err := client.NewRequest(ctx, http.MethodPatch, releaseURL.String(), bytes.NewBuffer(bodyBytes)) if err != nil { return nil, err } + + var release shared.Release + if _, err := client.Do(req, &release); err != nil { + return nil, err + } return &release, 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 deleteRelease(ctx context.Context, client *githubrest.Client, releaseURL safeurl.SafeURL) error { + req, err := client.NewRequest(ctx, http.MethodDelete, releaseURL.String(), nil) + if err != nil { + return err + } + + _, err = client.Do(req, nil) + return err } // tokenHasWorkflowScope checks if the response token has the workflow scope. @@ -237,9 +239,8 @@ func tokenHasWorkflowScope(headers http.Header) bool { } // isNewRelease checks if there are new commits since the latest release. -func isNewRelease(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { - ctx := context.Background() - release, err := shared.FetchLatestRelease(ctx, httpClient, repo) +func isNewRelease(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface) (bool, error) { + release, err := shared.FetchLatestRelease(ctx, client, repo) if err != nil { if errors.Is(err, shared.ErrReleaseNotFound) { return true, nil @@ -259,8 +260,12 @@ func isNewRelease(httpClient *http.Client, repo ghrepo.Interface) (bool, error) Status string `json:"status"` } - apiClient := api.NewClientFromHTTP(httpClient) - if err := apiClient.REST(repo.RepoHost(), "GET", u.String(), nil, &comparisonStatus); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return false, err + } + + if _, err := client.Do(req, &comparisonStatus); err != nil { return false, err } diff --git a/pkg/cmd/release/create/http_test.go b/pkg/cmd/release/create/http_test.go index ca016333948..c92107d1d0f 100644 --- a/pkg/cmd/release/create/http_test.go +++ b/pkg/cmd/release/create/http_test.go @@ -1,6 +1,7 @@ package create import ( + "context" "fmt" "net/http" "testing" @@ -21,7 +22,7 @@ func TestGetTagsHTTPError(t *testing.T) { httpmock.StatusStringResponse(http.StatusInternalServerError, `{"message":"Internal Server Error"}`), ) - _, err := getTags(&http.Client{Transport: reg}, ghrepo.New("OWNER", "REPO"), 5) + _, err := getTags(context.Background(), mustRESTClient(t, reg), ghrepo.New("OWNER", "REPO"), 5) requireAPIHTTPError(t, err, http.StatusInternalServerError) } @@ -34,7 +35,7 @@ func TestGenerateReleaseNotesNotImplemented(t *testing.T) { httpmock.StatusStringResponse(http.StatusNotFound, `{"message":"Not Found"}`), ) - _, err := generateReleaseNotes(&http.Client{Transport: reg}, ghrepo.New("OWNER", "REPO"), "v1.2.3", "", "") + _, err := generateReleaseNotes(context.Background(), mustRESTClient(t, reg), ghrepo.New("OWNER", "REPO"), "v1.2.3", "", "") assert.Same(t, notImplementedError, err) } @@ -47,7 +48,7 @@ func TestGenerateReleaseNotesHTTPError(t *testing.T) { httpmock.StatusStringResponse(http.StatusInternalServerError, `{"message":"Internal Server Error"}`), ) - _, err := generateReleaseNotes(&http.Client{Transport: reg}, ghrepo.New("OWNER", "REPO"), "v1.2.3", "", "") + _, err := generateReleaseNotes(context.Background(), mustRESTClient(t, reg), ghrepo.New("OWNER", "REPO"), "v1.2.3", "", "") requireAPIHTTPError(t, err, http.StatusInternalServerError) } @@ -60,7 +61,7 @@ func TestCreateReleaseMissingWorkflowScope(t *testing.T) { httpmock.StatusScopesResponder(http.StatusNotFound, "repo,read:org"), ) - _, err := createRelease(&http.Client{Transport: reg}, ghrepo.New("OWNER", "REPO"), map[string]interface{}{"tag_name": "v1.2.3"}) + _, err := createRelease(context.Background(), mustRESTClient(t, reg), ghrepo.New("OWNER", "REPO"), map[string]interface{}{"tag_name": "v1.2.3"}) var scopeErr *errMissingRequiredWorkflowScope require.ErrorAs(t, err, &scopeErr) @@ -76,7 +77,7 @@ func TestCreateReleaseHTTPErrorWithoutScopesHeader(t *testing.T) { httpmock.StatusStringResponse(http.StatusNotFound, `{"message":"Not Found"}`), ) - _, err := createRelease(&http.Client{Transport: reg}, ghrepo.New("OWNER", "REPO"), map[string]interface{}{"tag_name": "v1.2.3"}) + _, err := createRelease(context.Background(), mustRESTClient(t, reg), ghrepo.New("OWNER", "REPO"), map[string]interface{}{"tag_name": "v1.2.3"}) requireAPIHTTPError(t, err, http.StatusNotFound) } @@ -89,7 +90,7 @@ func TestPublishReleaseHTTPError(t *testing.T) { httpmock.StatusStringResponse(http.StatusInternalServerError, `{"message":"Internal Server Error"}`), ) - _, err := publishRelease(&http.Client{Transport: reg}, "github.com", safeurl.NewImmutableSafeURL("https://api.github.com/releases/123"), "", nil) + _, err := publishRelease(context.Background(), mustRESTClient(t, reg), safeurl.NewImmutableSafeURL("https://api.github.com/releases/123"), "", nil) requireAPIHTTPError(t, err, http.StatusInternalServerError) } @@ -102,7 +103,7 @@ func TestDeleteReleaseHTTPError(t *testing.T) { httpmock.StatusStringResponse(http.StatusInternalServerError, `{"message":"Internal Server Error"}`), ) - err := deleteRelease(&http.Client{Transport: reg}, "github.com", safeurl.NewImmutableSafeURL("https://api.github.com/releases/123")) + err := deleteRelease(context.Background(), mustRESTClient(t, reg), safeurl.NewImmutableSafeURL("https://api.github.com/releases/123")) requireAPIHTTPError(t, err, http.StatusInternalServerError) } @@ -169,3 +170,10 @@ func requireAPIHTTPError(t *testing.T, err error, statusCode int) { assert.Equal(t, statusCode, httpErr.StatusCode) assert.Contains(t, err.Error(), fmt.Sprintf("HTTP %d", statusCode)) } + +func mustRESTClient(t *testing.T, reg *httpmock.Registry) *githubrest.Client { + t.Helper() + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + return client +} diff --git a/pkg/cmd/release/delete-asset/delete_asset.go b/pkg/cmd/release/delete-asset/delete_asset.go index 3aedc3e3a46..175c1e72bf5 100644 --- a/pkg/cmd/release/delete-asset/delete_asset.go +++ b/pkg/cmd/release/delete-asset/delete_asset.go @@ -5,8 +5,8 @@ import ( "fmt" "net/http" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -20,6 +20,7 @@ type iprompter interface { type DeleteAssetOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Prompter iprompter @@ -33,6 +34,7 @@ func NewCmdDeleteAsset(f *cmdutil.Factory, runF func(*DeleteAssetOptions) error) opts := &DeleteAssetOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Prompter: f.Prompter, } @@ -48,7 +50,7 @@ func NewCmdDeleteAsset(f *cmdutil.Factory, runF func(*DeleteAssetOptions) error) if runF != nil { return runF(opts) } - return deleteAssetRun(opts) + return deleteAssetRun(cmd.Context(), opts) }, } @@ -57,7 +59,7 @@ func NewCmdDeleteAsset(f *cmdutil.Factory, runF func(*DeleteAssetOptions) error) return cmd } -func deleteAssetRun(opts *DeleteAssetOptions) error { +func deleteAssetRun(ctx context.Context, opts *DeleteAssetOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -68,7 +70,12 @@ func deleteAssetRun(opts *DeleteAssetOptions) error { return err } - release, err := shared.FetchRelease(context.Background(), httpClient, baseRepo, opts.TagName) + client, err := opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return err + } + + release, err := shared.FetchRelease(ctx, client, httpClient, baseRepo, opts.TagName) if err != nil { return err } @@ -97,7 +104,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(ctx, client, safeurl.NewImmutableSafeURL(assetURL)) if err != nil { return err } @@ -112,20 +119,12 @@ 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) +func deleteAsset(ctx context.Context, client *githubrest.Client, assetURL safeurl.SafeURL) error { + req, err := client.NewRequest(ctx, http.MethodDelete, assetURL.String(), nil) if err != nil { return err } - defer resp.Body.Close() - if resp.StatusCode > 299 { - return api.HandleHTTPError(resp) - } - return nil + _, err = client.Do(req, nil) + return err } diff --git a/pkg/cmd/release/delete-asset/delete_asset_test.go b/pkg/cmd/release/delete-asset/delete_asset_test.go index e302ca3d1f5..f02bcd9f0ac 100644 --- a/pkg/cmd/release/delete-asset/delete_asset_test.go +++ b/pkg/cmd/release/delete-asset/delete_asset_test.go @@ -2,6 +2,7 @@ package deleteasset import ( "bytes" + "context" "io" "net/http" "testing" @@ -182,11 +183,12 @@ func Test_deleteAssetRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: fakeHTTP}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(fakeHTTP) tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") } - err := deleteAssetRun(&tt.opts) + err := deleteAssetRun(context.Background(), &tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) return diff --git a/pkg/cmd/release/delete/delete.go b/pkg/cmd/release/delete/delete.go index 108ebae7ece..8df4bd2b12e 100644 --- a/pkg/cmd/release/delete/delete.go +++ b/pkg/cmd/release/delete/delete.go @@ -5,10 +5,10 @@ import ( "fmt" "net/http" - "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/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -22,6 +22,7 @@ type iprompter interface { type DeleteOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) GitClient *git.Client IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) @@ -37,6 +38,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co opts := &DeleteOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, GitClient: f.GitClient, Prompter: f.Prompter, } @@ -55,7 +57,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co if runF != nil { return runF(opts) } - return deleteRun(opts) + return deleteRun(cmd.Context(), opts) }, } @@ -65,7 +67,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return cmd } -func deleteRun(opts *DeleteOptions) error { +func deleteRun(ctx context.Context, opts *DeleteOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -76,7 +78,12 @@ func deleteRun(opts *DeleteOptions) error { return err } - release, err := shared.FetchRelease(context.Background(), httpClient, baseRepo, opts.TagName) + client, err := opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return err + } + + release, err := shared.FetchRelease(ctx, client, httpClient, baseRepo, opts.TagName) if err != nil { return err } @@ -93,14 +100,14 @@ func deleteRun(opts *DeleteOptions) error { } } - err = deleteRelease(httpClient, safeurl.NewImmutableSafeURL(release.APIURL)) + err = deleteRelease(ctx, client, safeurl.NewImmutableSafeURL(release.APIURL)) if err != nil { return err } var cleanupMessage string if opts.CleanupTag { - if err := deleteTag(httpClient, baseRepo, release.TagName); err != nil { + if err := deleteTag(ctx, client, baseRepo, release.TagName); err != nil { return err } if opts.RepoOverride == "" { @@ -122,42 +129,26 @@ 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) +func deleteRelease(ctx context.Context, client *githubrest.Client, releaseURL safeurl.SafeURL) error { + req, err := client.NewRequest(ctx, http.MethodDelete, releaseURL.String(), nil) if err != nil { return err } - defer resp.Body.Close() - if resp.StatusCode > 299 { - return api.HandleHTTPError(resp) - } - return nil + _, err = client.Do(req, nil) + return err } -func deleteTag(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) error { +func deleteTag(ctx context.Context, client *githubrest.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)) if err != nil { return err } - req, err := http.NewRequest("DELETE", url.String(), nil) - if err != nil { - return err - } - - resp, err := httpClient.Do(req) + req, err := client.NewRequest(ctx, http.MethodDelete, url.String(), nil) if err != nil { return err } - defer resp.Body.Close() - if resp.StatusCode > 299 { - return api.HandleHTTPError(resp) - } - return nil + _, err = client.Do(req, nil) + return err } diff --git a/pkg/cmd/release/delete/delete_test.go b/pkg/cmd/release/delete/delete_test.go index 13904ff3501..646c994211d 100644 --- a/pkg/cmd/release/delete/delete_test.go +++ b/pkg/cmd/release/delete/delete_test.go @@ -2,6 +2,7 @@ package delete import ( "bytes" + "context" "io" "net/http" "testing" @@ -223,12 +224,13 @@ func Test_deleteRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: fakeHTTP}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(fakeHTTP) tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") } tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} - err := deleteRun(&tt.opts) + err := deleteRun(context.Background(), &tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) return diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go index 688d94c1472..d19d18adf37 100644 --- a/pkg/cmd/release/download/download.go +++ b/pkg/cmd/release/download/download.go @@ -15,8 +15,8 @@ import ( "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -26,6 +26,7 @@ import ( type DownloadOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) OverwriteExisting bool @@ -47,6 +48,7 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr opts := &DownloadOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -103,7 +105,7 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr if runF != nil { return runF(opts) } - return downloadRun(opts) + return downloadRun(cmd.Context(), opts) }, } @@ -139,7 +141,7 @@ func checkArchiveTypeOption(opts *DownloadOptions) error { return nil } -func downloadRun(opts *DownloadOptions) error { +func downloadRun(ctx context.Context, opts *DownloadOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -150,19 +152,22 @@ func downloadRun(opts *DownloadOptions) error { return err } + client, err := opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return err + } + opts.IO.StartProgressIndicatorWithLabel("Finding assets to download") defer opts.IO.StopProgressIndicator() - ctx := context.Background() - var release *shared.Release if opts.TagName == "" { - release, err = shared.FetchLatestRelease(ctx, httpClient, baseRepo) + release, err = shared.FetchLatestRelease(ctx, client, baseRepo) if err != nil { return err } } else { - release, err = shared.FetchRelease(ctx, httpClient, baseRepo, opts.TagName) + release, err = shared.FetchRelease(ctx, client, httpClient, baseRepo, opts.TagName) if err != nil { return err } @@ -242,7 +247,24 @@ func downloadRun(opts *DownloadOptions) error { } } - return downloadAssets(&dest, httpClient, targets, opts.Concurrency, isArchive, opts.IO) + // Archive downloads redirect to Codeload, and the first hop can land on a + // "legacy" path that yields a differently-shaped archive. Rewriting it needs + // a redirect policy, which belongs to a client rather than to a request, so + // archives get a client of their own. + downloadClient := client + if isArchive { + downloadClient, err = opts.GitHubREST(baseRepo.RepoHost(), githubrest.WithCheckRedirect(func(req *http.Request, via []*http.Request) error { + if len(via) == 1 { + req.URL.Path = removeLegacyFromCodeloadPath(req.URL.Path) + } + return nil + })) + if err != nil { + return err + } + } + + return downloadAssets(ctx, &dest, downloadClient, targets, opts.Concurrency, isArchive, opts.IO) } func matchAny(patterns []string, name string) bool { @@ -259,7 +281,7 @@ type downloadTarget struct { name string } -func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload []downloadTarget, numWorkers int, isArchive bool, io *iostreams.IOStreams) error { +func downloadAssets(ctx context.Context, dest *destinationWriter, client *githubrest.Client, toDownload []downloadTarget, numWorkers int, isArchive bool, io *iostreams.IOStreams) error { if numWorkers == 0 { return errors.New("the number of concurrent workers needs to be greater than 0") } @@ -275,7 +297,7 @@ func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload go func() { for a := range jobs { io.StartProgressIndicatorWithLabel(fmt.Sprintf("Downloading %s", a.name)) - results <- downloadAsset(dest, httpClient, a.url, a.name, isArchive) + results <- downloadAsset(ctx, dest, client, a.url, a.name, isArchive) } }() } @@ -297,41 +319,32 @@ func downloadAssets(dest *destinationWriter, httpClient *http.Client, toDownload return downloadError } -func downloadAsset(dest *destinationWriter, httpClient *http.Client, assetURL safeurl.SafeURL, fileName string, isArchive bool) error { +func downloadAsset(ctx context.Context, dest *destinationWriter, client *githubrest.Client, assetURL safeurl.SafeURL, fileName string, isArchive bool) error { if err := dest.Check(fileName); err != nil { return err } - req, err := http.NewRequest("GET", assetURL.String(), nil) - if err != nil { - return err - } - - req.Header.Set("Accept", "application/octet-stream") + accept := "application/octet-stream" if isArchive { // adding application/json to Accept header due to a bug in the zipball/tarball API endpoint that makes it mandatory - req.Header.Set("Accept", "application/octet-stream, application/json") - - // override HTTP redirect logic to avoid "legacy" Codeload resources - oldClient := *httpClient - httpClient = &oldClient - httpClient.CheckRedirect = func(req *http.Request, via []*http.Request) error { - if len(via) == 1 { - req.URL.Path = removeLegacyFromCodeloadPath(req.URL.Path) - } - return nil - } + accept = "application/octet-stream, application/json" } - resp, err := httpClient.Do(req) + req, err := client.NewRequest(ctx, http.MethodGet, assetURL.String(), nil, githubrest.WithHeader("Accept", accept)) if err != nil { return err } - defer resp.Body.Close() - if resp.StatusCode > 299 { - return api.HandleHTTPError(resp) + // Send rather than Do, because the file name can come from a response header + // and the body is streamed to disk rather than decoded. + resp, err := client.Send(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return err } + defer resp.Body.Close() if len(fileName) == 0 { contentDisposition := resp.Header.Get("Content-Disposition") diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go index 855380c3856..26869165a37 100644 --- a/pkg/cmd/release/download/download_test.go +++ b/pkg/cmd/release/download/download_test.go @@ -2,6 +2,7 @@ package download import ( "bytes" + "context" "io" "net/http" "os" @@ -693,11 +694,12 @@ func Test_downloadRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: fakeHTTP}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(fakeHTTP) tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") } - err := downloadRun(&tt.opts) + err := downloadRun(context.Background(), &tt.opts) if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) return @@ -861,12 +863,13 @@ func Test_downloadRun_cloberAndSkip(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") } - err = downloadRun(&tt.opts) + err = downloadRun(context.Background(), &tt.opts) if tt.wantErr != "" { assert.ErrorContains(t, err, tt.wantErr) } else { @@ -913,13 +916,14 @@ func Test_downloadRun_windowsReservedFilename(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") }, TagName: tagName, } - err := downloadRun(opts) + err := downloadRun(context.Background(), opts) assert.EqualError(t, err, `unable to download release due to asset with reserved filename "CON.tgz"`) } diff --git a/pkg/cmd/release/edit/edit.go b/pkg/cmd/release/edit/edit.go index 95abf5e2054..32ca792c77e 100644 --- a/pkg/cmd/release/edit/edit.go +++ b/pkg/cmd/release/edit/edit.go @@ -7,6 +7,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -16,6 +17,7 @@ import ( type EditOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) BaseRepo func() (ghrepo.Interface, error) TagName string @@ -33,6 +35,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman opts := &EditOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, } var notesFile string @@ -69,7 +72,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return editRun(args[0], opts) + return editRun(cmd.Context(), args[0], opts) }, } @@ -89,7 +92,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman return cmd } -func editRun(tag string, opts *EditOptions) error { +func editRun(ctx context.Context, tag string, opts *EditOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -100,7 +103,12 @@ func editRun(tag string, opts *EditOptions) error { return err } - release, err := shared.FetchRelease(context.Background(), httpClient, baseRepo, tag) + client, err := opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return err + } + + release, err := shared.FetchRelease(ctx, client, httpClient, baseRepo, tag) if err != nil { return err } @@ -123,7 +131,7 @@ func editRun(tag string, opts *EditOptions) error { } } - editedRelease, err := editRelease(httpClient, baseRepo, release.DatabaseID, params) + editedRelease, err := editRelease(ctx, client, baseRepo, release.DatabaseID, params) if err != nil { return err } diff --git a/pkg/cmd/release/edit/edit_test.go b/pkg/cmd/release/edit/edit_test.go index 180051d1271..4674b7dc033 100644 --- a/pkg/cmd/release/edit/edit_test.go +++ b/pkg/cmd/release/edit/edit_test.go @@ -2,6 +2,7 @@ package edit import ( "bytes" + "context" "fmt" "io" "net/http" @@ -454,11 +455,12 @@ func Test_editRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: fakeHTTP}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(fakeHTTP) tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") } - err := editRun("v1.2.3", &tt.opts) + err := editRun(context.Background(), "v1.2.3", &tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) return diff --git a/pkg/cmd/release/edit/http.go b/pkg/cmd/release/edit/http.go index 291123ad3ea..198e915f2b0 100644 --- a/pkg/cmd/release/edit/http.go +++ b/pkg/cmd/release/edit/http.go @@ -2,21 +2,22 @@ package edit import ( "bytes" + "context" "encoding/json" "fmt" - "io" "net/http" "strconv" "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/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/release/shared" "github.com/shurcooL/githubv4" ) -func editRelease(httpClient *http.Client, repo ghrepo.Interface, releaseID int64, params map[string]interface{}) (*shared.Release, error) { +func editRelease(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, releaseID int64, params map[string]interface{}) (*shared.Release, error) { bodyBytes, err := json.Marshal(params) if err != nil { return nil, err @@ -26,32 +27,17 @@ func editRelease(httpClient *http.Client, repo ghrepo.Interface, releaseID int64 if err != nil { return nil, err } - req, err := http.NewRequest("PATCH", url.String(), bytes.NewBuffer(bodyBytes)) + req, err := client.NewRequest(ctx, http.MethodPatch, url.String(), bytes.NewBuffer(bodyBytes), + githubrest.WithHeader("Content-Type", "application/json; charset=utf-8")) if err != nil { return nil, err } - req.Header.Set("Content-Type", "application/json; charset=utf-8") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - success := resp.StatusCode >= 200 && resp.StatusCode < 300 - if !success { - return nil, api.HandleHTTPError(resp) - } - - b, err := io.ReadAll(resp.Body) - if err != nil { + var newRelease shared.Release + if _, err := client.Do(req, &newRelease); err != nil { return nil, err } - - var newRelease shared.Release - err = json.Unmarshal(b, &newRelease) - return &newRelease, err + return &newRelease, nil } func remoteTagExists(httpClient *http.Client, repo ghrepo.Interface, tagName string) (bool, error) { diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go index 74bf06657a1..8a0235f6bb9 100644 --- a/pkg/cmd/release/shared/fetch.go +++ b/pkg/cmd/release/shared/fetch.go @@ -2,10 +2,8 @@ package shared import ( "context" - "encoding/json" "errors" "fmt" - "io" "net/http" "reflect" "strconv" @@ -16,6 +14,7 @@ import ( "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/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/httpmock" "github.com/shurcooL/githubv4" @@ -137,39 +136,28 @@ type fetchResult struct { error error } -func FetchRefSHA(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (string, error) { +func FetchRefSHA(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, tagName string) (string, error) { url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "git", "ref", fmt.Sprintf("tags/%s", tagName)) if err != nil { return "", err } - req, err := http.NewRequestWithContext(ctx, "GET", url.String(), nil) + req, err := client.NewRequest(ctx, http.MethodGet, 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 == http.StatusNotFound { - _, _ = io.Copy(io.Discard, resp.Body) - return "", ErrReleaseNotFound - } - - if resp.StatusCode > 299 { - return "", api.HandleHTTPError(resp) - } - var ref struct { Object struct { SHA string `json:"sha"` } `json:"object"` } - if err := json.NewDecoder(resp.Body).Decode(&ref); err != nil { - return "", fmt.Errorf("failed to parse ref response: %w", err) + if _, err := client.Do(req, &ref); err != nil { + var errResp *githubrest.ErrorResponse + if errors.As(err, &errResp) && errResp.StatusCode == http.StatusNotFound { + return "", ErrReleaseNotFound + } + return "", err } return ref.Object.SHA, nil @@ -189,7 +177,7 @@ func DigestAlgForRef(digest string) string { } // FetchRelease finds a published repository release by its tagName, or a draft release by its pending tag name. -func FetchRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (*Release, error) { +func FetchRelease(ctx context.Context, client *githubrest.Client, httpClient *http.Client, repo ghrepo.Interface, tagName string) (*Release, error) { publishedURL, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "releases", "tags", tagName) if err != nil { return nil, err @@ -200,13 +188,13 @@ func FetchRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Inte // published release lookup go func() { - release, err := fetchReleasePath(cc, httpClient, publishedURL) + release, err := fetchReleasePath(cc, client, publishedURL) results <- fetchResult{release: release, error: err} }() // draft release lookup go func() { - release, err := fetchDraftRelease(cc, httpClient, repo, tagName) + release, err := fetchDraftRelease(cc, client, httpClient, repo, tagName) results <- fetchResult{release: release, error: err} }() @@ -234,16 +222,18 @@ func FetchRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Inte } // FetchLatestRelease finds the latest published release for a repository. -func FetchLatestRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface) (*Release, error) { +func FetchLatestRelease(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface) (*Release, error) { url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "releases", "latest") if err != nil { return nil, err } - return fetchReleasePath(ctx, httpClient, url) + return fetchReleasePath(ctx, client, url) } // fetchDraftRelease returns the first draft release that has tagName as its pending tag. -func fetchDraftRelease(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, tagName string) (*Release, error) { +// fetchDraftRelease takes both clients because it asks GraphQL a question REST +// cannot answer and then reads the answer back over REST. +func fetchDraftRelease(ctx context.Context, client *githubrest.Client, httpClient *http.Client, repo ghrepo.Interface, tagName string) (*Release, error) { // First use GraphQL to find a draft release by pending tag name, since REST doesn't have this ability. var query struct { Repository struct { @@ -275,30 +265,21 @@ func fetchDraftRelease(ctx context.Context, httpClient *http.Client, repo ghrepo if err != nil { return nil, err } - return fetchReleasePath(ctx, httpClient, path) + return fetchReleasePath(ctx, client, path) } -func fetchReleasePath(ctx context.Context, httpClient *http.Client, url safeurl.SafeURL) (*Release, error) { - req, err := http.NewRequestWithContext(ctx, "GET", url.String(), nil) - if err != nil { - return nil, err - } - - resp, err := httpClient.Do(req) +func fetchReleasePath(ctx context.Context, client *githubrest.Client, url safeurl.SafeURL) (*Release, error) { + req, err := client.NewRequest(ctx, http.MethodGet, url.String(), nil) if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNotFound { - _, _ = io.Copy(io.Discard, resp.Body) - return nil, ErrReleaseNotFound - } else if resp.StatusCode > 299 { - return nil, api.HandleHTTPError(resp) - } var release Release - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + if _, err := client.Do(req, &release); err != nil { + var errResp *githubrest.ErrorResponse + if errors.As(err, &errResp) && errResp.StatusCode == http.StatusNotFound { + return nil, ErrReleaseNotFound + } return nil, err } diff --git a/pkg/cmd/release/shared/fetch_test.go b/pkg/cmd/release/shared/fetch_test.go index e68278f1ed1..160d342a5e8 100644 --- a/pkg/cmd/release/shared/fetch_test.go +++ b/pkg/cmd/release/shared/fetch_test.go @@ -2,7 +2,6 @@ package shared import ( "context" - "net/http" "testing" "github.com/cli/go-gh/v2/pkg/api" @@ -49,7 +48,7 @@ func TestFetchRefSHA(t *testing.T) { tagName: "v1.2.3", responseStatus: 200, responseBody: `{"object": {"sha":`, - errorMessage: "failed to parse ref response: unexpected EOF", + errorMessage: "unexpected end of JSON input", }, } @@ -77,10 +76,11 @@ func TestFetchRefSHA(t *testing.T) { ) } - httpClient := &http.Client{Transport: fakeHTTP} + client, err := httpmock.RESTClientFunc(fakeHTTP)("github.com") + require.NoError(t, err) ctx := context.Background() - sha, err := FetchRefSHA(ctx, httpClient, repo, tt.tagName) + sha, err := FetchRefSHA(ctx, client, repo, tt.tagName) if tt.errorMessage != "" { assert.Contains(t, err.Error(), tt.errorMessage) diff --git a/pkg/cmd/release/shared/upload.go b/pkg/cmd/release/shared/upload.go index c20d4db3962..c08a3e727d2 100644 --- a/pkg/cmd/release/shared/upload.go +++ b/pkg/cmd/release/shared/upload.go @@ -2,7 +2,6 @@ package shared import ( "context" - "encoding/json" "errors" "io" "mime" @@ -14,17 +13,12 @@ import ( "time" "github.com/cenkalti/backoff/v4" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" "golang.org/x/sync/errgroup" ) -type httpDoer interface { - Do(*http.Request) (*http.Response, error) -} - type errNetwork struct{ error } type AssetForUpload struct { @@ -113,19 +107,18 @@ func fileExt(fn string) string { return path.Ext(fn) } -func ConcurrentUpload(httpClient httpDoer, uploadURL safeurl.SafeURL, numWorkers int, assets []*AssetForUpload) error { +func ConcurrentUpload(ctx context.Context, client *githubrest.Client, uploadURL safeurl.SafeURL, numWorkers int, assets []*AssetForUpload) error { if numWorkers == 0 { return errors.New("the number of concurrent workers needs to be greater than 0") } - ctx := context.Background() g, gctx := errgroup.WithContext(ctx) g.SetLimit(numWorkers) for _, a := range assets { asset := *a g.Go(func() error { - return uploadWithDelete(gctx, httpClient, uploadURL, asset) + return uploadWithDelete(gctx, client, uploadURL, asset) }) } @@ -144,15 +137,15 @@ func shouldRetry(err error) bool { // Allow injecting backoff interval in tests. var retryInterval = time.Millisecond * 200 -func uploadWithDelete(ctx context.Context, httpClient httpDoer, uploadURL safeurl.SafeURL, a AssetForUpload) error { +func uploadWithDelete(ctx context.Context, client *githubrest.Client, uploadURL safeurl.SafeURL, a AssetForUpload) error { if a.ExistingURL != nil && a.ExistingURL.String() != "" { - if err := deleteAsset(ctx, httpClient, a.ExistingURL); err != nil { + if err := deleteAsset(ctx, client, a.ExistingURL); err != nil { return err } } bo := backoff.NewConstantBackOff(retryInterval) return backoff.Retry(func() error { - _, err := uploadAsset(ctx, httpClient, uploadURL, a) + _, err := uploadAsset(ctx, client, uploadURL, a) if err == nil || shouldRetry(err) { return err } @@ -160,7 +153,7 @@ func uploadWithDelete(ctx context.Context, httpClient httpDoer, uploadURL safeur }, backoff.WithContext(backoff.WithMaxRetries(bo, 3), ctx)) } -func uploadAsset(ctx context.Context, httpClient httpDoer, uploadURL safeurl.SafeURL, asset AssetForUpload) (*ReleaseAsset, error) { +func uploadAsset(ctx context.Context, client *githubrest.Client, uploadURL safeurl.SafeURL, asset AssetForUpload) (*ReleaseAsset, error) { u, err := url.Parse(uploadURL.String()) if err != nil { return nil, err @@ -173,56 +166,31 @@ func uploadAsset(ctx context.Context, httpClient httpDoer, uploadURL safeurl.Saf // Since u is derived from uploadURL, an already-trusted safeurl.SafeURL, the resulting URL is safe to declare as such. safeURL := safeurl.NewImmutableSafeURL(u.String()) - f, err := asset.Open() + req, err := client.NewUploadRequest(ctx, safeURL.String(), asset.Open, asset.Size, asset.MIMEType) if err != nil { return nil, err } - defer f.Close() - - req, err := http.NewRequestWithContext(ctx, "POST", safeURL.String(), f) - if err != nil { - return nil, err - } - req.ContentLength = asset.Size - req.Header.Set("Content-Type", asset.MIMEType) - req.GetBody = asset.Open - - resp, err := httpClient.Do(req) - if err != nil { - return nil, errNetwork{err} - } - defer resp.Body.Close() - - success := resp.StatusCode >= 200 && resp.StatusCode < 300 - if !success { - return nil, api.HandleHTTPError(resp) - } var newAsset ReleaseAsset - dec := json.NewDecoder(resp.Body) - if err := dec.Decode(&newAsset); err != nil { + if _, err := client.Do(req, &newAsset); err != nil { + // A retry only makes sense when the request never reached the server, so + // a transport failure is marked and an API error is left as it is. + var errResp *githubrest.ErrorResponse + if !errors.As(err, &errResp) { + return nil, errNetwork{err} + } return nil, err } return &newAsset, nil } -func deleteAsset(ctx context.Context, httpClient httpDoer, assetURL safeurl.SafeURL) error { - req, err := http.NewRequestWithContext(ctx, "DELETE", assetURL.String(), nil) +func deleteAsset(ctx context.Context, client *githubrest.Client, assetURL safeurl.SafeURL) error { + req, err := client.NewRequest(ctx, http.MethodDelete, assetURL.String(), nil) if err != nil { return err } - resp, err := httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - success := resp.StatusCode >= 200 && resp.StatusCode < 300 - if !success { - return api.HandleHTTPError(resp) - } - - return nil + _, err = client.Do(req, nil) + return err } diff --git a/pkg/cmd/release/shared/upload_test.go b/pkg/cmd/release/shared/upload_test.go index 8271fa6ae6e..3aa7ff7a55c 100644 --- a/pkg/cmd/release/shared/upload_test.go +++ b/pkg/cmd/release/shared/upload_test.go @@ -8,6 +8,7 @@ import ( "net/http" "testing" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -82,7 +83,7 @@ func Test_uploadWithDelete_retry(t *testing.T) { ctx := context.Background() tries := 0 - client := funcClient(func(req *http.Request) (*http.Response, error) { + tr := roundTripperFunc(func(req *http.Request) (*http.Response, error) { tries++ if tries == 1 { return nil, errors.New("made up exception") @@ -99,7 +100,12 @@ func Test_uploadWithDelete_retry(t *testing.T) { Body: io.NopCloser(bytes.NewBufferString(`{}`)), }, nil }) - err := uploadWithDelete(ctx, client, safeurl.NewImmutableSafeURL("http://example.com/upload"), AssetForUpload{ + client, err := githubrest.NewClient("http://example.com/", &http.Client{Transport: tr}, githubrest.WithoutToken()) + if err != nil { + t.Fatal(err) + } + + err = uploadWithDelete(ctx, client, safeurl.NewImmutableSafeURL("http://example.com/upload"), AssetForUpload{ Name: "asset", Label: "", Size: 8, @@ -116,8 +122,8 @@ func Test_uploadWithDelete_retry(t *testing.T) { } } -type funcClient func(*http.Request) (*http.Response, error) +type roundTripperFunc func(*http.Request) (*http.Response, error) -func (f funcClient) Do(req *http.Request) (*http.Response, error) { +func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } diff --git a/pkg/cmd/release/upload/upload.go b/pkg/cmd/release/upload/upload.go index 35b10860d75..069b9879b62 100644 --- a/pkg/cmd/release/upload/upload.go +++ b/pkg/cmd/release/upload/upload.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/release/shared" @@ -19,6 +20,7 @@ import ( type UploadOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) @@ -34,6 +36,7 @@ func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Co opts := &UploadOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -66,7 +69,7 @@ func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Co if runF != nil { return runF(opts) } - return uploadRun(opts) + return uploadRun(cmd.Context(), opts) }, } @@ -75,7 +78,7 @@ func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Co return cmd } -func uploadRun(opts *UploadOptions) error { +func uploadRun(ctx context.Context, opts *UploadOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -86,7 +89,12 @@ func uploadRun(opts *UploadOptions) error { return err } - release, err := shared.FetchRelease(context.Background(), httpClient, baseRepo, opts.TagName) + client, err := opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return err + } + + release, err := shared.FetchRelease(ctx, client, httpClient, baseRepo, opts.TagName) if err != nil { return err } @@ -113,7 +121,7 @@ func uploadRun(opts *UploadOptions) error { } opts.IO.StartProgressIndicator() - err = shared.ConcurrentUpload(httpClient, safeurl.NewImmutableSafeURL(uploadURL), opts.Concurrency, opts.Assets) + err = shared.ConcurrentUpload(ctx, client, safeurl.NewImmutableSafeURL(uploadURL), opts.Concurrency, opts.Assets) opts.IO.StopProgressIndicator() if err != nil { return err diff --git a/pkg/cmd/release/verify-asset/verify_asset.go b/pkg/cmd/release/verify-asset/verify_asset.go index 9adacf2cae2..5b1e069375c 100644 --- a/pkg/cmd/release/verify-asset/verify_asset.go +++ b/pkg/cmd/release/verify-asset/verify_asset.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" att_io "github.com/cli/cli/v2/pkg/cmd/attestation/io" @@ -29,6 +30,7 @@ type VerifyAssetOptions struct { type VerifyAssetConfig struct { HttpClient *http.Client + GitHubREST *githubrest.Client IO *iostreams.IOStreams Opts *VerifyAssetOptions AttClient api.Client @@ -88,6 +90,11 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*VerifyAssetConfig) error) return err } + restClient, err := f.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return err + } + io := f.IOStreams attClient := api.NewLiveClient(httpClient, externalClient, baseRepo.RepoHost(), att_io.NewHandler(io)) @@ -101,6 +108,7 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*VerifyAssetConfig) error) config := &VerifyAssetConfig{ Opts: opts, HttpClient: httpClient, + GitHubREST: restClient, AttClient: attClient, AttVerifier: attVerifier, IO: io, @@ -127,7 +135,7 @@ func verifyAssetRun(config *VerifyAssetConfig) error { tagName := opts.TagName if tagName == "" { - release, err := shared.FetchLatestRelease(ctx, config.HttpClient, baseRepo) + release, err := shared.FetchLatestRelease(ctx, config.GitHubREST, baseRepo) if err != nil { return err } @@ -142,7 +150,7 @@ func verifyAssetRun(config *VerifyAssetConfig) error { return err } - ref, err := shared.FetchRefSHA(ctx, config.HttpClient, baseRepo, tagName) + ref, err := shared.FetchRefSHA(ctx, config.GitHubREST, baseRepo, tagName) if err != nil { return err } diff --git a/pkg/cmd/release/verify-asset/verify_asset_test.go b/pkg/cmd/release/verify-asset/verify_asset_test.go index 7535735aa40..af559c24605 100644 --- a/pkg/cmd/release/verify-asset/verify_asset_test.go +++ b/pkg/cmd/release/verify-asset/verify_asset_test.go @@ -17,6 +17,7 @@ import ( "github.com/stretchr/testify/require" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" ) func TestNewCmdVerifyAsset_Args(t *testing.T) { @@ -57,6 +58,7 @@ func TestNewCmdVerifyAsset_Args(t *testing.T) { ExternalHttpClient: func() (*http.Client, error) { return nil, nil }, + GitHubREST: httpmock.RESTClientFunc(&httpmock.Registry{}), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, @@ -118,6 +120,7 @@ func Test_verifyAssetRun_Success(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, + GitHubREST: mustRESTClient(t, fakeHTTP), AttClient: api.NewTestClient(), AttVerifier: shared.NewMockVerifier(result), } @@ -161,6 +164,7 @@ func Test_verifyAssetRun_SuccessNoTagArg(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, + GitHubREST: mustRESTClient(t, fakeHTTP), AttClient: api.NewTestClient(), AttVerifier: shared.NewMockVerifier(result), } @@ -200,6 +204,7 @@ func Test_verifyAssetRun_FailedNoAttestations_SHA1(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, + GitHubREST: mustRESTClient(t, fakeHTTP), AttClient: attClient, AttVerifier: nil, } @@ -241,6 +246,7 @@ func Test_verifyAssetRun_FailedNoAttestations_SHA256(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, + GitHubREST: mustRESTClient(t, fakeHTTP), AttClient: attClient, AttVerifier: nil, } @@ -279,6 +285,7 @@ func Test_verifyAssetRun_FailedTagNotInAttestation(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, + GitHubREST: mustRESTClient(t, fakeHTTP), AttClient: api.NewTestClient(), AttVerifier: nil, } @@ -310,6 +317,7 @@ func Test_verifyAssetRun_FailedInvalidAsset(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, + GitHubREST: mustRESTClient(t, fakeHTTP), AttClient: api.NewTestClient(), AttVerifier: nil, } @@ -338,6 +346,7 @@ func Test_verifyAssetRun_NoSuchAsset(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, + GitHubREST: mustRESTClient(t, fakeHTTP), AttClient: api.NewTestClient(), AttVerifier: nil, } @@ -362,3 +371,10 @@ func Test_getFileName(t *testing.T) { }) } } + +func mustRESTClient(t *testing.T, reg *httpmock.Registry) *githubrest.Client { + t.Helper() + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + return client +} diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index c1a9ae4a20a..cf45b88813c 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -10,6 +10,7 @@ import ( "google.golang.org/protobuf/encoding/protojson" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/artifact" @@ -31,6 +32,7 @@ type VerifyOptions struct { type VerifyConfig struct { HttpClient *http.Client + GitHubREST *githubrest.Client IO *iostreams.IOStreams Opts *VerifyOptions AttClient api.Client @@ -84,6 +86,11 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(config *VerifyConfig) error) *co return err } + restClient, err := f.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return err + } + io := f.IOStreams attClient := api.NewLiveClient(httpClient, externalClient, baseRepo.RepoHost(), att_io.NewHandler(io)) @@ -97,6 +104,7 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(config *VerifyConfig) error) *co config := &VerifyConfig{ Opts: opts, HttpClient: httpClient, + GitHubREST: restClient, AttClient: attClient, AttVerifier: attVerifier, IO: io, @@ -122,7 +130,7 @@ func verifyRun(config *VerifyConfig) error { tagName := opts.TagName if tagName == "" { - release, err := shared.FetchLatestRelease(ctx, config.HttpClient, baseRepo) + release, err := shared.FetchLatestRelease(ctx, config.GitHubREST, baseRepo) if err != nil { return err } @@ -130,7 +138,7 @@ func verifyRun(config *VerifyConfig) error { } // Retrieve the ref for the release tag - ref, err := shared.FetchRefSHA(ctx, config.HttpClient, baseRepo, tagName) + ref, err := shared.FetchRefSHA(ctx, config.GitHubREST, baseRepo, tagName) if err != nil { return err } diff --git a/pkg/cmd/release/verify/verify_test.go b/pkg/cmd/release/verify/verify_test.go index e2d29bb584b..977dd82ab98 100644 --- a/pkg/cmd/release/verify/verify_test.go +++ b/pkg/cmd/release/verify/verify_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/attestation/api" "github.com/cli/cli/v2/pkg/cmd/attestation/test/data" "github.com/cli/cli/v2/pkg/cmd/attestation/verification" @@ -46,6 +47,7 @@ func TestNewCmdVerify_Args(t *testing.T) { ExternalHttpClient: func() (*http.Client, error) { return nil, nil }, + GitHubREST: httpmock.RESTClientFunc(&httpmock.Registry{}), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, @@ -98,6 +100,7 @@ func Test_verifyRun_Success(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, + GitHubREST: mustRESTClient(t, fakeHTTP), AttClient: api.NewTestClient(), AttVerifier: shared.NewMockVerifier(result), } @@ -134,6 +137,7 @@ func Test_verifyRun_FailedNoAttestations_SHA1(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, + GitHubREST: mustRESTClient(t, fakeHTTP), AttClient: attClient, AttVerifier: nil, } @@ -172,6 +176,7 @@ func Test_verifyRun_FailedNoAttestations_SHA256(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, + GitHubREST: mustRESTClient(t, fakeHTTP), AttClient: attClient, AttVerifier: nil, } @@ -207,6 +212,7 @@ func Test_verifyRun_FailedTagNotInAttestation(t *testing.T) { }, IO: ios, HttpClient: &http.Client{Transport: fakeHTTP}, + GitHubREST: mustRESTClient(t, fakeHTTP), AttClient: api.NewTestClient(), AttVerifier: nil, } @@ -214,3 +220,10 @@ func Test_verifyRun_FailedTagNotInAttestation(t *testing.T) { err = verifyRun(cfg) require.ErrorContains(t, err, "no attestations found for release v1.2.3") } + +func mustRESTClient(t *testing.T, reg *httpmock.Registry) *githubrest.Client { + t.Helper() + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + return client +} diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index 4e88bf3aa9c..48876b6c980 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -10,6 +10,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/release/shared" @@ -25,6 +26,7 @@ type browser interface { type ViewOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Browser browser @@ -38,6 +40,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman opts := &ViewOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Browser: f.Browser, } @@ -83,16 +86,21 @@ func viewRun(opts *ViewOptions) error { return err } + client, err := opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return err + } + ctx := context.Background() var release *shared.Release if opts.TagName == "" { - release, err = shared.FetchLatestRelease(ctx, httpClient, baseRepo) + release, err = shared.FetchLatestRelease(ctx, client, baseRepo) if err != nil { return err } } else { - release, err = shared.FetchRelease(ctx, httpClient, baseRepo, opts.TagName) + release, err = shared.FetchRelease(ctx, client, httpClient, baseRepo, opts.TagName) if err != nil { return err } diff --git a/pkg/cmd/release/view/view_test.go b/pkg/cmd/release/view/view_test.go index 95b00c6e261..e3aec3f3570 100644 --- a/pkg/cmd/release/view/view_test.go +++ b/pkg/cmd/release/view/view_test.go @@ -263,6 +263,7 @@ func Test_viewRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: fakeHTTP}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(fakeHTTP) tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("OWNER/REPO") } From b65b8cc84cbf4197733fc0995121711998f8a48a Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 14:39:51 +0200 Subject: [PATCH 09/21] Route repo delete, gist create and pr diff through githubrest These are the RequireScopes, SendIgnoringRedirects and streaming-body call sites, so between them they cover the parts of the surface that exist for something other than a plain JSON round trip. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- pkg/cmd/gist/create/create.go | 47 +++++++++++++----------------- pkg/cmd/gist/create/create_test.go | 8 ++--- pkg/cmd/pr/diff/diff.go | 31 ++++++++++---------- pkg/cmd/pr/diff/diff_test.go | 7 ++--- pkg/cmd/repo/delete/delete.go | 14 +++++++-- pkg/cmd/repo/delete/delete_test.go | 4 ++- pkg/cmd/repo/delete/http.go | 33 +++++++++++---------- 7 files changed, 75 insertions(+), 69 deletions(-) diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go index 9bc19360e0e..d61ff2336b3 100644 --- a/pkg/cmd/gist/create/create.go +++ b/pkg/cmd/gist/create/create.go @@ -2,6 +2,7 @@ package create import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -14,7 +15,6 @@ import ( "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghinstance" @@ -37,7 +37,7 @@ type CreateOptions struct { WebMode bool Config func() (gh.Config, error) - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Browser browser.Browser } @@ -45,7 +45,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := CreateOptions{ IO: f.IOStreams, Config: f.Config, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Browser: f.Browser, } @@ -95,7 +95,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co if runF != nil { return runF(&opts) } - return createRun(&opts) + return createRun(c.Context(), &opts) }, } @@ -106,7 +106,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return cmd } -func createRun(opts *CreateOptions) error { +func createRun(ctx context.Context, opts *CreateOptions) error { readFromStdInArg, filenames := cmdutil.Partition(opts.Filenames, func(f string) bool { return f == "-" @@ -142,11 +142,6 @@ func createRun(opts *CreateOptions) error { } fmt.Fprintf(errOut, "%s %s\n", cs.Muted("-"), processMessage) - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - cfg, err := opts.Config() if err != nil { return err @@ -155,7 +150,12 @@ func createRun(opts *CreateOptions) error { host, _ := cfg.Authentication().DefaultHost() opts.IO.StartProgressIndicator() - gist, err := createGist(httpClient, host, opts.Description, opts.Public, files) + restClient, err := opts.GitHubREST(host) + if err != nil { + return err + } + + gist, err := createGist(ctx, restClient, host, opts.Description, opts.Public, files) opts.IO.StopProgressIndicator() if err != nil { var httpError *githubrest.ErrorResponse @@ -261,7 +261,7 @@ func guessGistName(files map[string]*shared.GistFile) string { return gistName } -func createGist(client *http.Client, hostname, description string, public bool, files map[string]*shared.GistFile) (*shared.Gist, error) { +func createGist(ctx context.Context, client *githubrest.Client, hostname, description string, public bool, files map[string]*shared.GistFile) (*shared.Gist, error) { body := &shared.Gist{ Description: description, Public: public, @@ -278,26 +278,21 @@ func createGist(client *http.Client, hostname, description string, public bool, if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodPost, u.String(), requestBody) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json; charset=utf-8") - resp, err := client.Do(req) + req, err := client.NewRequest(ctx, http.MethodPost, u.String(), requestBody, + githubrest.WithHeader("Content-Type", "application/json; charset=utf-8")) if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode > 299 { - api.EndpointNeedsScopes(resp, "gist") - return nil, api.HandleHTTPError(resp) - } result := &shared.Gist{} - dec := json.NewDecoder(resp.Body) - if err := dec.Decode(result); err != nil { + if _, err := client.Do(req, result); err != nil { + var errResp *githubrest.ErrorResponse + if errors.As(err, &errResp) { + // The endpoint does not report the scope it needs, so a token + // without it would otherwise fail with a bare permission error. + errResp.RequireScopes("gist") + } return nil, err } diff --git a/pkg/cmd/gist/create/create_test.go b/pkg/cmd/gist/create/create_test.go index 44f0ba284ef..50ea4543f08 100644 --- a/pkg/cmd/gist/create/create_test.go +++ b/pkg/cmd/gist/create/create_test.go @@ -2,6 +2,7 @@ package create import ( "bytes" + "context" "encoding/json" "io" "net/http" @@ -351,10 +352,7 @@ func Test_createRun(t *testing.T) { httpmock.StatusStringResponse(tt.responseStatus, "{}")) } - mockClient := func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - tt.opts.HttpClient = mockClient + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil @@ -372,7 +370,7 @@ func Test_createRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { stdin.WriteString(tt.stdin) - if err := createRun(tt.opts); (err != nil) != tt.wantErr { + if err := createRun(context.Background(), tt.opts); (err != nil) != tt.wantErr { t.Errorf("createRun() error = %v, wantErr %v", err, tt.wantErr) } bodyBytes, _ := io.ReadAll(reg.Requests[0].Body) diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index 3e627b8b806..ba7ccb1c333 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -3,6 +3,7 @@ package diff import ( "bufio" "bytes" + "context" "errors" "fmt" "io" @@ -13,10 +14,10 @@ import ( "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -28,7 +29,7 @@ import ( ) type DiffOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Browser browser.Browser @@ -47,7 +48,7 @@ type DiffOptions struct { func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Command { opts := &DiffOptions{ IO: f.IOStreams, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Browser: f.Browser, } @@ -111,7 +112,7 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return diffRun(opts) + return diffRun(cmd.Context(), opts) }, } @@ -125,7 +126,7 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman return cmd } -func diffRun(opts *DiffOptions) error { +func diffRun(ctx context.Context, opts *DiffOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, Fields: []string{"number"}, @@ -148,7 +149,7 @@ func diffRun(opts *DiffOptions) error { return opts.Browser.Browse(openUrl) } - httpClient, err := opts.HttpClient() + client, err := opts.GitHubREST(baseRepo.RepoHost()) if err != nil { return err } @@ -157,7 +158,7 @@ func diffRun(opts *DiffOptions) error { opts.Patch = false } - diffReadCloser, err := fetchDiff(httpClient, baseRepo, pr.Number, opts.Patch) + diffReadCloser, err := fetchDiff(ctx, client, baseRepo, pr.Number, opts.Patch) if err != nil { return fmt.Errorf("could not find pull request diff: %w", err) } @@ -210,7 +211,7 @@ func diffRun(opts *DiffOptions) error { return err } -func fetchDiff(httpClient *http.Client, baseRepo ghrepo.Interface, prNumber int, asPatch bool) (io.ReadCloser, error) { +func fetchDiff(ctx context.Context, client *githubrest.Client, baseRepo ghrepo.Interface, prNumber int, asPatch bool) (io.ReadCloser, error) { url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(baseRepo.RepoHost()), "repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "pulls", strconv.Itoa(prNumber)) if err != nil { return nil, err @@ -220,20 +221,20 @@ func fetchDiff(httpClient *http.Client, baseRepo ghrepo.Interface, prNumber int, acceptType = "application/vnd.github.v3.patch" } - req, err := http.NewRequest("GET", url.String(), nil) + req, err := client.NewRequest(ctx, http.MethodGet, url.String(), nil, githubrest.WithHeader("Accept", acceptType)) if err != nil { return nil, err } - req.Header.Set("Accept", acceptType) - - resp, err := httpClient.Do(req) + // Send rather than Do, because a diff is streamed to the terminal as it + // arrives rather than read into memory. + resp, err := client.Send(req) if err != nil { + if resp != nil { + resp.Body.Close() + } return nil, err } - if resp.StatusCode != 200 { - return nil, api.HandleHTTPError(resp) - } return resp.Body, nil } diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go index b95ac8a8311..ed293b05a61 100644 --- a/pkg/cmd/pr/diff/diff_test.go +++ b/pkg/cmd/pr/diff/diff_test.go @@ -2,6 +2,7 @@ package diff import ( "bytes" + "context" "fmt" "io" "net/http" @@ -356,9 +357,7 @@ index f2b4805c..3d7bd0f9 100644 if tt.httpStubs != nil { tt.httpStubs(httpReg) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: httpReg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(httpReg) browser := &browser.Stub{} tt.opts.Browser = browser @@ -371,7 +370,7 @@ index f2b4805c..3d7bd0f9 100644 finder.ExpectFields(tt.wantFields) tt.opts.Finder = finder - err := diffRun(&tt.opts) + err := diffRun(context.Background(), &tt.opts) if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) } else { diff --git a/pkg/cmd/repo/delete/delete.go b/pkg/cmd/repo/delete/delete.go index 28a55a3404a..7ef2964f223 100644 --- a/pkg/cmd/repo/delete/delete.go +++ b/pkg/cmd/repo/delete/delete.go @@ -1,6 +1,7 @@ package delete import ( + "context" "errors" "fmt" "net/http" @@ -22,6 +23,7 @@ type iprompter interface { type DeleteOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) BaseRepo func() (ghrepo.Interface, error) Prompter iprompter IO *iostreams.IOStreams @@ -33,6 +35,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co opts := &DeleteOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, BaseRepo: f.BaseRepo, Prompter: f.Prompter, } @@ -75,7 +78,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return runF(opts) } - return deleteRun(opts) + return deleteRun(cmd.Context(), opts) }, } @@ -85,7 +88,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return cmd } -func deleteRun(opts *DeleteOptions) error { +func deleteRun(ctx context.Context, opts *DeleteOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -122,7 +125,12 @@ func deleteRun(opts *DeleteOptions) error { } } - err = deleteRepo(httpClient, toDelete) + client, err := opts.GitHubREST(toDelete.RepoHost()) + if err != nil { + return err + } + + err = deleteRepo(ctx, client, toDelete) if err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { diff --git a/pkg/cmd/repo/delete/delete_test.go b/pkg/cmd/repo/delete/delete_test.go index 64a8ee30161..3562e6b28df 100644 --- a/pkg/cmd/repo/delete/delete_test.go +++ b/pkg/cmd/repo/delete/delete_test.go @@ -2,6 +2,7 @@ package delete import ( "bytes" + "context" "net/http" "testing" @@ -218,6 +219,7 @@ func Test_deleteRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, stderr := iostreams.Test() ios.SetStdinTTY(tt.tty) @@ -226,7 +228,7 @@ func Test_deleteRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { defer reg.Verify(t) - err := deleteRun(tt.opts) + err := deleteRun(context.Background(), tt.opts) if tt.wantErr { assert.Error(t, err) assert.Equal(t, tt.errMsg, err.Error()) diff --git a/pkg/cmd/repo/delete/http.go b/pkg/cmd/repo/delete/http.go index faffa006e3d..fc774f96c5b 100644 --- a/pkg/cmd/repo/delete/http.go +++ b/pkg/cmd/repo/delete/http.go @@ -1,41 +1,44 @@ package delete import ( + "context" + "errors" "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/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) -func deleteRepo(client *http.Client, repo ghrepo.Interface) error { - oldClient := *client - client = &oldClient - client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - +// deleteRepo stops at a redirect rather than following it, because a repository +// that has been renamed or transferred answers with one, and following it would +// delete whatever now lives at the new location. +func deleteRepo(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface) error { url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName()) if err != nil { return err } - request, err := http.NewRequest("DELETE", url.String(), nil) + req, err := client.NewRequest(ctx, http.MethodDelete, url.String(), nil) if err != nil { return err } - resp, err := client.Do(request) + resp, err := client.SendIgnoringRedirects(req) if err != nil { + var errResp *githubrest.ErrorResponse + if errors.As(err, &errResp) { + // The endpoint does not report the scope it needs, so a 403 would + // otherwise say only that access was denied. + errResp.RequireScopes("delete_repo") + } + if resp != nil { + resp.Body.Close() + } return err } defer resp.Body.Close() - if resp.StatusCode > 299 { - api.EndpointNeedsScopes(resp, "delete_repo") - return api.HandleHTTPError(resp) - } - return nil } From eee560d171ea28a875c21572dfa99cea3bdef4a5 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 14:42:29 +0200 Subject: [PATCH 10/21] Route gh run through githubrest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- pkg/cmd/run/download/download.go | 5 +-- pkg/cmd/run/download/download_test.go | 10 +++--- pkg/cmd/run/download/http.go | 29 ++++++++++-------- pkg/cmd/run/download/http_test.go | 19 +++++++++--- pkg/cmd/run/shared/artifacts.go | 24 ++++++++------- pkg/cmd/run/shared/artifacts_test.go | 8 +++-- pkg/cmd/run/view/logs.go | 33 +++++++++++--------- pkg/cmd/run/view/logs_test.go | 11 ++++--- pkg/cmd/run/view/view.go | 44 +++++++++++++++++---------- pkg/cmd/run/view/view_test.go | 4 ++- 10 files changed, 113 insertions(+), 74 deletions(-) diff --git a/pkg/cmd/run/download/download.go b/pkg/cmd/run/download/download.go index 347c17251df..24e12cf86f9 100644 --- a/pkg/cmd/run/download/download.go +++ b/pkg/cmd/run/download/download.go @@ -83,12 +83,13 @@ func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobr if err != nil { return err } - httpClient, err := f.HttpClient() + client, err := f.GitHubREST(baseRepo.RepoHost()) if err != nil { return err } opts.Platform = &apiPlatform{ - client: httpClient, + ctx: cmd.Context(), + client: client, repo: baseRepo, } diff --git a/pkg/cmd/run/download/download_test.go b/pkg/cmd/run/download/download_test.go index a001cd78111..82364a79064 100644 --- a/pkg/cmd/run/download/download_test.go +++ b/pkg/cmd/run/download/download_test.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "net/http" "os" "path/filepath" "testing" @@ -16,6 +15,7 @@ import ( "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/run/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" @@ -106,12 +106,10 @@ func Test_NewCmdDownload(t *testing.T) { ios.SetStderrTTY(tt.isTTY) f := &cmdutil.Factory{ - IOStreams: ios, - HttpClient: func() (*http.Client, error) { - return nil, nil - }, + IOStreams: ios, + GitHubREST: httpmock.RESTClientFunc(&httpmock.Registry{}), BaseRepo: func() (ghrepo.Interface, error) { - return nil, nil + return ghrepo.New("OWNER", "REPO"), nil }, } diff --git a/pkg/cmd/run/download/http.go b/pkg/cmd/run/download/http.go index a832f924b20..8119e554445 100644 --- a/pkg/cmd/run/download/http.go +++ b/pkg/cmd/run/download/http.go @@ -2,13 +2,14 @@ package download import ( "archive/zip" + "context" "fmt" "io" "net/http" "os" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/safeurl" ghzip "github.com/cli/cli/v2/internal/zip" @@ -16,36 +17,38 @@ import ( ) type apiPlatform struct { - client *http.Client + ctx context.Context + client *githubrest.Client repo ghrepo.Interface } func (p *apiPlatform) List(runID string) ([]shared.Artifact, error) { - return shared.ListArtifacts(p.client, p.repo, runID) + return shared.ListArtifacts(p.ctx, p.client, p.repo, runID) } func (p *apiPlatform) Download(url safeurl.SafeURL, dir safepaths.Absolute) error { - return downloadArtifact(p.client, url, dir) + return downloadArtifact(p.ctx, p.client, url, dir) } -func downloadArtifact(httpClient *http.Client, url safeurl.SafeURL, destDir safepaths.Absolute) error { - req, err := http.NewRequest("GET", url.String(), nil) +func downloadArtifact(ctx context.Context, client *githubrest.Client, url safeurl.SafeURL, destDir safepaths.Absolute) error { + // The server rejects an Accept of application/zip, so the request asks for + // nothing in particular and takes what it is given. + req, err := client.NewRequest(ctx, http.MethodGet, url.String(), nil) if err != nil { return err } - // The server rejects this :( - //req.Header.Set("Accept", "application/zip") - resp, err := httpClient.Do(req) + // Send rather than Do, because the archive is streamed to a temporary file + // so it can be read back as a zip, which needs a ReaderAt. + resp, err := client.Send(req) if err != nil { + if resp != nil { + resp.Body.Close() + } return err } defer resp.Body.Close() - if resp.StatusCode > 299 { - return api.HandleHTTPError(resp) - } - tmpfile, err := os.CreateTemp("", "gh-artifact.*.zip") if err != nil { return fmt.Errorf("error initializing temporary file: %w", err) diff --git a/pkg/cmd/run/download/http_test.go b/pkg/cmd/run/download/http_test.go index 5b68dff82d4..4bf43d2b9da 100644 --- a/pkg/cmd/run/download/http_test.go +++ b/pkg/cmd/run/download/http_test.go @@ -1,7 +1,7 @@ package download import ( - "net/http" + "context" "os" "path/filepath" "sort" @@ -9,6 +9,7 @@ import ( "testing" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/httpmock" @@ -31,7 +32,8 @@ func Test_List(t *testing.T) { }`)) api := &apiPlatform{ - client: &http.Client{Transport: reg}, + ctx: context.Background(), + client: mustRESTClient(t, reg), repo: ghrepo.New("OWNER", "REPO"), } artifacts, err := api.List("123") @@ -51,7 +53,8 @@ func Test_List_perRepository(t *testing.T) { httpmock.StringResponse(`{}`)) api := &apiPlatform{ - client: &http.Client{Transport: reg}, + ctx: context.Background(), + client: mustRESTClient(t, reg), repo: ghrepo.New("OWNER", "REPO"), } _, err := api.List("") @@ -71,7 +74,8 @@ func Test_Download(t *testing.T) { httpmock.FileResponse("./fixtures/myproject.zip")) api := &apiPlatform{ - client: &http.Client{Transport: reg}, + ctx: context.Background(), + client: mustRESTClient(t, reg), } require.NoError(t, api.Download(safeurl.NewImmutableSafeURL("https://api.github.com/repos/OWNER/REPO/actions/artifacts/12345/zip"), destDir)) @@ -106,3 +110,10 @@ func Test_Download(t *testing.T) { filepath.Join("artifact", "src", "util.go"), }, paths) } + +func mustRESTClient(t *testing.T, reg *httpmock.Registry) *githubrest.Client { + t.Helper() + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + return client +} diff --git a/pkg/cmd/run/shared/artifacts.go b/pkg/cmd/run/shared/artifacts.go index 36d0b39e73c..9909f230871 100644 --- a/pkg/cmd/run/shared/artifacts.go +++ b/pkg/cmd/run/shared/artifacts.go @@ -1,14 +1,15 @@ package shared import ( + "context" "encoding/json" "net/http" "regexp" "strconv" - "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/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -23,7 +24,7 @@ type artifactsPayload struct { Artifacts []Artifact } -func ListArtifacts(httpClient *http.Client, repo ghrepo.Interface, runID string) ([]Artifact, error) { +func ListArtifacts(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, runID string) ([]Artifact, error) { var results []Artifact restPrefix := ghinstance.RESTPrefix(repo.RepoHost()) @@ -43,7 +44,7 @@ func ListArtifacts(httpClient *http.Client, repo ghrepo.Interface, runID string) for { var payload artifactsPayload - nextURL, err := apiGet(httpClient, pageURL, &payload) + nextURL, err := apiGet(ctx, client, pageURL, &payload) if err != nil { return nil, err } @@ -58,28 +59,29 @@ func ListArtifacts(httpClient *http.Client, repo ghrepo.Interface, runID string) return results, nil } -func apiGet(httpClient *http.Client, url safeurl.SafeURL, data interface{}) (string, error) { - req, err := http.NewRequest("GET", url.String(), nil) +func apiGet(ctx context.Context, client *githubrest.Client, url safeurl.SafeURL, data interface{}) (string, error) { + req, err := client.NewRequest(ctx, http.MethodGet, url.String(), nil) if err != nil { return "", err } - resp, err := httpClient.Do(req) + // Send rather than Do, because pagination is driven by the Link header, + // which only the response carries. + resp, err := client.Send(req) if err != nil { + if resp != nil { + resp.Body.Close() + } return "", err } defer resp.Body.Close() - if resp.StatusCode > 299 { - return "", api.HandleHTTPError(resp) - } - dec := json.NewDecoder(resp.Body) if err := dec.Decode(data); err != nil { return "", err } - return findNextPage(resp), nil + return findNextPage(resp.Response), nil } var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) diff --git a/pkg/cmd/run/shared/artifacts_test.go b/pkg/cmd/run/shared/artifacts_test.go index 31345598e92..94583e28129 100644 --- a/pkg/cmd/run/shared/artifacts_test.go +++ b/pkg/cmd/run/shared/artifacts_test.go @@ -1,14 +1,15 @@ package shared import ( + "context" "fmt" - "net/http" "net/url" "testing" "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 TestDownloadWorkflowArtifactsPageinates(t *testing.T) { @@ -57,10 +58,11 @@ func TestDownloadWorkflowArtifactsPageinates(t *testing.T) { reg.Register(firstReq, firstRes) reg.Register(secondReq, secondRes) - httpClient := &http.Client{Transport: reg} + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) repo := ghrepo.New(testRepoOwner, testRepoName) - result, err := ListArtifacts(httpClient, repo, testRunId) + result, err := ListArtifacts(context.Background(), client, repo, testRunId) assert.NoError(t, err) assert.Equal(t, []Artifact{firstArtifact, secondArtifact}, result) } diff --git a/pkg/cmd/run/view/logs.go b/pkg/cmd/run/view/logs.go index ab1232837bb..07fd0068a09 100644 --- a/pkg/cmd/run/view/logs.go +++ b/pkg/cmd/run/view/logs.go @@ -2,6 +2,7 @@ package view import ( "archive/zip" + "context" "errors" "fmt" "io" @@ -13,9 +14,9 @@ import ( "strings" "unicode/utf16" - "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/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/run/shared" ) @@ -33,7 +34,8 @@ func (f *zipLogFetcher) GetLog() (io.ReadCloser, error) { } type apiLogFetcher struct { - httpClient *http.Client + ctx context.Context + client *githubrest.Client repo ghrepo.Interface jobID int64 @@ -45,22 +47,24 @@ func (f *apiLogFetcher) GetLog() (io.ReadCloser, error) { return nil, err } - req, err := http.NewRequest("GET", logURL.String(), nil) + req, err := f.client.NewRequest(f.ctx, http.MethodGet, logURL.String(), nil) if err != nil { return nil, err } - resp, err := f.httpClient.Do(req) + // Send rather than Do, because the caller reads the log as a stream. + resp, err := f.client.Send(req) if err != nil { + if resp != nil { + resp.Body.Close() + } + var errResp *githubrest.ErrorResponse + if errors.As(err, &errResp) && errResp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("log not found: %v", f.jobID) + } return nil, err } - if resp.StatusCode == 404 { - return nil, fmt.Errorf("log not found: %v", f.jobID) - } else if resp.StatusCode != 200 { - return nil, api.HandleHTTPError(resp) - } - return resp.Body, nil } @@ -90,7 +94,7 @@ var errTooManyAPILogFetchers = errors.New("too many missing logs") // Note that, as heuristic approach, we only allow a limited number of API log // fetchers to be assigned. This is to avoid overwhelming the API with too many // requests. -func populateLogSegments(httpClient *http.Client, repo ghrepo.Interface, jobs []shared.Job, zlm *zipLogMap, onlyFailed bool) ([]logSegment, error) { +func populateLogSegments(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, jobs []shared.Job, zlm *zipLogMap, onlyFailed bool) ([]logSegment, error) { segments := make([]logSegment, 0, len(jobs)) apiLogFetcherCount := 0 @@ -139,9 +143,10 @@ func populateLogSegments(httpClient *http.Client, repo ghrepo.Interface, jobs [] segment.fetcher = &zipLogFetcher{File: zf} } else { segment.fetcher = &apiLogFetcher{ - httpClient: httpClient, - repo: repo, - jobID: job.ID, + ctx: ctx, + client: client, + repo: repo, + jobID: job.ID, } apiLogFetcherCount++ } diff --git a/pkg/cmd/run/view/logs_test.go b/pkg/cmd/run/view/logs_test.go index b49a605ab41..ba5cdea8900 100644 --- a/pkg/cmd/run/view/logs_test.go +++ b/pkg/cmd/run/view/logs_test.go @@ -3,6 +3,7 @@ package view import ( "archive/zip" "bytes" + "context" "io" "net/http" "testing" @@ -104,12 +105,14 @@ func TestApiLogFetcher(t *testing.T) { tt.httpStubs(reg) - httpClient := &http.Client{Transport: reg} + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) fetcher := &apiLogFetcher{ - httpClient: httpClient, - repo: ghrepo.New("OWNER", "REPO"), - jobID: 123, + ctx: context.Background(), + client: client, + repo: ghrepo.New("OWNER", "REPO"), + jobID: 123, } rc, err := fetcher.GetLog() diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index 95cdc891291..a4043e06c88 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bufio" "bytes" + "context" "errors" "fmt" "io" @@ -18,6 +19,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/run/shared" @@ -79,6 +81,7 @@ func (c RunLogCache) filepath(key string) string { type ViewOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser @@ -104,6 +107,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman opts := &ViewOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Prompter: f.Prompter, Now: time.Now, Browser: f.Browser, @@ -188,7 +192,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return runView(opts) + return runView(cmd.Context(), opts) }, } cmd.Flags().BoolVarP(&opts.Verbose, "verbose", "v", false, "Show job steps") @@ -204,7 +208,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman return cmd } -func runView(opts *ViewOptions) error { +func runView(ctx context.Context, opts *ViewOptions) error { httpClient, err := opts.HttpClient() if err != nil { return fmt.Errorf("failed to create http client: %w", err) @@ -216,6 +220,11 @@ func runView(opts *ViewOptions) error { return fmt.Errorf("failed to determine base repo: %w", err) } + restClient, err := opts.GitHubREST(repo.RepoHost()) + if err != nil { + return fmt.Errorf("failed to create REST client: %w", err) + } + jobID := opts.JobID runID := opts.RunID attempt := opts.Attempt @@ -320,7 +329,7 @@ func runView(opts *ViewOptions) error { } opts.IO.StartProgressIndicator() - runLogZip, err := getRunLog(opts.RunLogCache, httpClient, repo, run, attempt) + runLogZip, err := getRunLog(ctx, opts.RunLogCache, restClient, repo, run, attempt) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get run log: %w", err) @@ -328,7 +337,7 @@ func runView(opts *ViewOptions) error { defer runLogZip.Close() zlm := getZipLogMap(&runLogZip.Reader, jobs) - segments, err := populateLogSegments(httpClient, repo, jobs, zlm, opts.LogFailed) + segments, err := populateLogSegments(ctx, restClient, repo, jobs, zlm, opts.LogFailed) if err != nil { if errors.Is(err, errTooManyAPILogFetchers) { return fmt.Errorf("too many API requests needed to fetch logs; try narrowing down to a specific job with the `--job` option") @@ -354,7 +363,7 @@ func runView(opts *ViewOptions) error { var artifacts []shared.Artifact if selectedJob == nil { - artifacts, err = shared.ListArtifacts(httpClient, repo, strconv.FormatInt(int64(run.ID), 10)) + artifacts, err = shared.ListArtifacts(ctx, restClient, repo, strconv.FormatInt(int64(run.ID), 10)) if err != nil { return fmt.Errorf("failed to get artifacts: %w", err) } @@ -470,27 +479,30 @@ func shouldFetchJobs(opts *ViewOptions) bool { return false } -func getLog(httpClient *http.Client, logURL safeurl.SafeURL) (io.ReadCloser, error) { - req, err := http.NewRequest("GET", logURL.String(), nil) +func getLog(ctx context.Context, client *githubrest.Client, logURL safeurl.SafeURL) (io.ReadCloser, error) { + req, err := client.NewRequest(ctx, http.MethodGet, logURL.String(), nil) if err != nil { return nil, err } - resp, err := httpClient.Do(req) + // Send rather than Do, because the archive is read into memory by the + // caller so it can be validated as a zip before it is cached. + resp, err := client.Send(req) if err != nil { + if resp != nil { + resp.Body.Close() + } + var errResp *githubrest.ErrorResponse + if errors.As(err, &errResp) && errResp.StatusCode == http.StatusNotFound { + return nil, errors.New("log not found") + } return nil, err } - if resp.StatusCode == 404 { - return nil, errors.New("log not found") - } else if resp.StatusCode != 200 { - return nil, api.HandleHTTPError(resp) - } - return resp.Body, nil } -func getRunLog(cache RunLogCache, httpClient *http.Client, repo ghrepo.Interface, run *shared.Run, attempt uint64) (*zip.ReadCloser, error) { +func getRunLog(ctx context.Context, cache RunLogCache, client *githubrest.Client, repo ghrepo.Interface, run *shared.Run, attempt uint64) (*zip.ReadCloser, error) { cacheKey := fmt.Sprintf("%d-%d", run.ID, run.StartedTime().Unix()) isCached, err := cache.Exists(cacheKey) if err != nil { @@ -511,7 +523,7 @@ func getRunLog(cache RunLogCache, httpClient *http.Client, repo ghrepo.Interface } } - resp, err := getLog(httpClient, logURL) + resp, err := getLog(ctx, client, logURL) if err != nil { return nil, err } diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go index c3ee9a54ad8..88003ee76c3 100644 --- a/pkg/cmd/run/view/view_test.go +++ b/pkg/cmd/run/view/view_test.go @@ -2,6 +2,7 @@ package view import ( "bytes" + "context" "fmt" "io" "net/http" @@ -2657,6 +2658,7 @@ func TestViewRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Now = func() time.Time { notnow, _ := time.Parse("2006-01-02 15:04:05", "2021-02-23 05:50:00") @@ -2683,7 +2685,7 @@ func TestViewRun(t *testing.T) { } t.Run(tt.name, func(t *testing.T) { - err := runView(tt.opts) + err := runView(context.Background(), tt.opts) if tt.wantErr { assert.Error(t, err) if tt.errMsg != "" { From 68dd51f7b8f8e2af549dc2b40e144516bc33e2f6 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 14:46:14 +0200 Subject: [PATCH 11/21] Route auth scope checks through githubrest GetScopes runs against a token that is not the client's own, so it takes an anonymous client and passes the token per request. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- pkg/cmd/auth/login/login.go | 48 +++++++++++++++--------- pkg/cmd/auth/login/login_test.go | 7 +++- pkg/cmd/auth/refresh/refresh.go | 33 ++++++++++------ pkg/cmd/auth/refresh/refresh_test.go | 4 +- pkg/cmd/auth/shared/login_flow.go | 13 ++++++- pkg/cmd/auth/shared/login_flow_test.go | 15 +++++++- pkg/cmd/auth/shared/oauth_scopes.go | 41 ++++++++++---------- pkg/cmd/auth/shared/oauth_scopes_test.go | 8 +++- pkg/cmd/auth/status/status.go | 44 +++++++++++++++------- pkg/cmd/auth/status/status_test.go | 3 +- 10 files changed, 144 insertions(+), 72 deletions(-) diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 24d30c56244..eb0291d0d0d 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -1,6 +1,7 @@ package login import ( + "context" "fmt" "io" "net/http" @@ -11,6 +12,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/auth/shared" "github.com/cli/cli/v2/pkg/cmd/auth/shared/gitcredentials" "github.com/cli/cli/v2/pkg/cmdutil" @@ -20,13 +22,14 @@ import ( ) type LoginOptions struct { - IO *iostreams.IOStreams - Config func() (gh.Config, error) - HttpClient func() (*http.Client, error) - PlainHttpClient func() (*http.Client, error) - GitClient *git.Client - Prompter shared.Prompt - Browser browser.Browser + IO *iostreams.IOStreams + Config func() (gh.Config, error) + HttpClient func() (*http.Client, error) + PlainHttpClient func() (*http.Client, error) + GitHubRESTAnonymous func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) + GitClient *git.Client + Prompter shared.Prompt + Browser browser.Browser MainExecutable string @@ -44,13 +47,14 @@ type LoginOptions struct { func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { opts := &LoginOptions{ - IO: f.IOStreams, - Config: f.Config, - HttpClient: f.HttpClient, - PlainHttpClient: f.PlainHttpClient, - GitClient: f.GitClient, - Prompter: f.Prompter, - Browser: f.Browser, + IO: f.IOStreams, + Config: f.Config, + HttpClient: f.HttpClient, + PlainHttpClient: f.PlainHttpClient, + GitHubRESTAnonymous: f.GitHubRESTAnonymous, + GitClient: f.GitClient, + Prompter: f.Prompter, + Browser: f.Browser, } var tokenStdin bool @@ -143,7 +147,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm return runF(opts) } - return loginRun(opts) + return loginRun(cmd.Context(), opts) }, } @@ -165,7 +169,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm return cmd } -func loginRun(opts *LoginOptions) error { +func loginRun(ctx context.Context, opts *LoginOptions) error { cfg, err := opts.Config() if err != nil { return err @@ -192,6 +196,13 @@ func loginRun(opts *LoginOptions) error { return cmdutil.SilentError } + // An anonymous client, because every call below authenticates as a token + // that is being validated rather than as the configured one. + restClient, err := opts.GitHubRESTAnonymous(hostname) + if err != nil { + return err + } + plainHTTPClient, err := opts.PlainHttpClient() if err != nil { return err @@ -203,7 +214,7 @@ func loginRun(opts *LoginOptions) error { } if opts.Token != "" { - if err := shared.HasMinimumScopes(httpClient, hostname, opts.Token); err != nil { + if err := shared.HasMinimumScopes(ctx, restClient, hostname, opts.Token); err != nil { return fmt.Errorf("error validating token: %w", err) } username, err := shared.GetCurrentLogin(httpClient, hostname, opts.Token) @@ -216,11 +227,12 @@ func loginRun(opts *LoginOptions) error { return loginErr } - return shared.Login(&shared.LoginOptions{ + return shared.Login(ctx, &shared.LoginOptions{ IO: opts.IO, Config: authCfg, HTTPClient: httpClient, PlainHTTPClient: plainHTTPClient, + RESTClient: restClient, Hostname: hostname, Interactive: opts.Interactive, Web: opts.Web, diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 7ec17497359..cd283e037d7 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -2,6 +2,7 @@ package login import ( "bytes" + "context" "fmt" "net/http" "regexp" @@ -483,6 +484,7 @@ func Test_loginRun_nontty(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubRESTAnonymous = httpmock.RESTClientFuncAnonymous(reg) tt.opts.PlainHttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } @@ -497,7 +499,7 @@ func Test_loginRun_nontty(t *testing.T) { _, restoreRun := run.Stub() defer restoreRun(t) - err := loginRun(tt.opts) + err := loginRun(context.Background(), tt.opts) if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) } else { @@ -778,6 +780,7 @@ func Test_loginRun_Survey(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubRESTAnonymous = httpmock.RESTClientFuncAnonymous(reg) tt.opts.PlainHttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } @@ -810,7 +813,7 @@ func Test_loginRun_Survey(t *testing.T) { tt.runStubs(rs) } - err := loginRun(tt.opts) + err := loginRun(context.Background(), tt.opts) if err != nil { t.Fatalf("unexpected error: %s", err) } diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 842902502cd..8a4199457ce 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -1,6 +1,7 @@ package refresh import ( + "context" "fmt" "net/http" "strings" @@ -9,6 +10,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/authflow" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/auth/shared" "github.com/cli/cli/v2/pkg/cmd/auth/shared/gitcredentials" "github.com/cli/cli/v2/pkg/cmdutil" @@ -21,11 +23,12 @@ type token string type username string type RefreshOptions struct { - IO *iostreams.IOStreams - Config func() (gh.Config, error) - PlainHttpClient func() (*http.Client, error) - GitClient *git.Client - Prompter shared.Prompt + IO *iostreams.IOStreams + Config func() (gh.Config, error) + PlainHttpClient func() (*http.Client, error) + GitHubRESTAnonymous func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) + GitClient *git.Client + Prompter shared.Prompt MainExecutable string @@ -48,9 +51,10 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. t, u, err := authflow.AuthFlow(httpClient, hostname, io, "", scopes, interactive, f.Browser, clipboard) return token(t), username(u), err }, - PlainHttpClient: f.PlainHttpClient, - GitClient: f.GitClient, - Prompter: f.Prompter, + PlainHttpClient: f.PlainHttpClient, + GitHubRESTAnonymous: f.GitHubRESTAnonymous, + GitClient: f.GitClient, + Prompter: f.Prompter, } cmd := &cobra.Command{ @@ -105,7 +109,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. if runF != nil { return runF(opts) } - return refreshRun(opts) + return refreshRun(cmd.Context(), opts) }, } @@ -124,7 +128,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. return cmd } -func refreshRun(opts *RefreshOptions) error { +func refreshRun(ctx context.Context, opts *RefreshOptions) error { plainHTTPClient, err := opts.PlainHttpClient() if err != nil { return err @@ -174,9 +178,16 @@ func refreshRun(opts *RefreshOptions) error { additionalScopes := set.NewStringSet() + // An anonymous client, because the scopes wanted are those of the token + // being replaced, not of whichever token is active now. + restClient, err := opts.GitHubRESTAnonymous(hostname) + if err != nil { + return err + } + if !opts.ResetScopes { if oldToken, _ := authCfg.ActiveToken(hostname); oldToken != "" { - if oldScopes, err := shared.GetScopes(plainHTTPClient, hostname, oldToken); err == nil { + if oldScopes, err := shared.GetScopes(ctx, restClient, hostname, oldToken); err == nil { for _, s := range strings.Split(oldScopes, ",") { s = strings.TrimSpace(s) if s != "" { diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index 120f6efec60..6f0ef25d69e 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -2,6 +2,7 @@ package refresh import ( "bytes" + "context" "io" "net/http" "strings" @@ -517,6 +518,7 @@ func Test_refreshRun(t *testing.T) { tt.opts.PlainHttpClient = func() (*http.Client, error) { return &http.Client{Transport: httpReg}, nil } + tt.opts.GitHubRESTAnonymous = httpmock.RESTClientFuncAnonymous(httpReg) pm := &prompter.PrompterMock{} if tt.prompterStubs != nil { @@ -524,7 +526,7 @@ func Test_refreshRun(t *testing.T) { } tt.opts.Prompter = pm - err := refreshRun(tt.opts) + err := refreshRun(context.Background(), tt.opts) if tt.wantErr != "" { require.Contains(t, err.Error(), tt.wantErr) return diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index c76dc5fb84c..69cb427a853 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -2,6 +2,7 @@ package shared import ( "bytes" + "context" "encoding/json" "fmt" "net/http" @@ -14,6 +15,7 @@ import ( "github.com/cli/cli/v2/internal/authflow" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/ssh-key/add" "github.com/cli/cli/v2/pkg/iostreams" @@ -31,6 +33,7 @@ type LoginOptions struct { IO *iostreams.IOStreams Config iconfig HTTPClient *http.Client + RESTClient *githubrest.Client PlainHTTPClient *http.Client Hostname string Interactive bool @@ -47,7 +50,7 @@ type LoginOptions struct { sshContext ssh.Context } -func Login(opts *LoginOptions) error { +func Login(ctx context.Context, opts *LoginOptions) error { cfg := opts.Config hostname := opts.Hostname httpClient := opts.HTTPClient @@ -169,7 +172,7 @@ func Login(opts *LoginOptions) error { return err } - if err := HasMinimumScopes(httpClient, hostname, authToken); err != nil { + if err := HasMinimumScopes(ctx, opts.RESTClient, hostname, authToken); err != nil { return fmt.Errorf("error validating token: %w", err) } } @@ -250,6 +253,12 @@ func sshKeyUpload(httpClient *http.Client, hostname, keyFile string, title strin return add.SSHKeyUpload(httpClient, hostname, f, title) } +// httpClient is the GraphQL leg of login, which is out of scope for the REST +// migration and still takes a token per request. +type httpClient interface { + Do(*http.Request) (*http.Response, error) +} + func GetCurrentLogin(httpClient httpClient, hostname, authToken string) (string, error) { query := `query UserCurrent{viewer{login}}` reqBody, err := json.Marshal(map[string]interface{}{"query": query}) diff --git a/pkg/cmd/auth/shared/login_flow_test.go b/pkg/cmd/auth/shared/login_flow_test.go index 31cf2c107e4..5cc6095bdf8 100644 --- a/pkg/cmd/auth/shared/login_flow_test.go +++ b/pkg/cmd/auth/shared/login_flow_test.go @@ -1,6 +1,7 @@ package shared import ( + "context" "fmt" "net/http" "os" @@ -8,6 +9,7 @@ import ( "testing" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/auth/shared/gitcredentials" @@ -261,6 +263,7 @@ func TestLogin(t *testing.T) { tt.opts.IO = ios tt.opts.Config = &cfg tt.opts.HTTPClient = &http.Client{Transport: reg} + tt.opts.RESTClient = mustRESTClient(t, reg) tt.opts.CredentialFlow = &GitCredentialFlow{ // Intentionally not instantiating anything in here because the tests do not hit this code path. // Right now it's better to panic if we write a test that hits the code than say, start calling @@ -273,7 +276,7 @@ func TestLogin(t *testing.T) { tt.runStubs(t, rs, &tt.opts) } - err := Login(&tt.opts) + err := Login(context.Background(), &tt.opts) if tt.wantsErr != "" { assert.EqualError(t, err, tt.wantsErr) @@ -313,6 +316,7 @@ func TestAuthenticatingGitCredentials(t *testing.T) { IO: ios, Config: tinyConfig{}, HTTPClient: &http.Client{Transport: reg}, + RESTClient: mustRESTClient(t, reg), Hostname: "example.com", Interactive: true, GitProtocol: "https", @@ -341,7 +345,7 @@ func TestAuthenticatingGitCredentials(t *testing.T) { }, } - require.NoError(t, Login(opts)) + require.NoError(t, Login(context.Background(), opts)) helper, err := opts.CredentialFlow.HelperConfig.ConfiguredHelper("example.com") require.NoError(t, err) @@ -387,3 +391,10 @@ func Test_scopesSentence(t *testing.T) { }) } } + +func mustRESTClient(t *testing.T, reg *httpmock.Registry) *githubrest.Client { + t.Helper() + client, err := httpmock.RESTClientFuncAnonymous(reg)("example.com") + require.NoError(t, err) + return client +} diff --git a/pkg/cmd/auth/shared/oauth_scopes.go b/pkg/cmd/auth/shared/oauth_scopes.go index bc5e611163a..fda8bc31353 100644 --- a/pkg/cmd/auth/shared/oauth_scopes.go +++ b/pkg/cmd/auth/shared/oauth_scopes.go @@ -1,13 +1,14 @@ package shared import ( + "context" "fmt" "io" "net/http" "strings" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -28,47 +29,47 @@ func (e MissingScopesError) Error() string { return "missing required scopes " + scopes } -type httpClient interface { - Do(*http.Request) (*http.Response, error) -} - -// GetScopes performs a GitHub API request and returns the value of the X-Oauth-Scopes header. -func GetScopes(httpClient httpClient, hostname, authToken string) (string, error) { +// GetScopes performs a GitHub API request and returns the value of the +// X-Oauth-Scopes header. +// +// The token is passed per request rather than carried by the client, because +// these checks run against a token that is not yet, or no longer, the one the +// client would authenticate as: a token the user has just pasted, the previous +// token during a refresh, or each token in turn in gh auth status. The client +// is therefore expected to be an anonymous one. +func GetScopes(ctx context.Context, client *githubrest.Client, hostname, authToken string) (string, error) { apiEndpoint, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(hostname)) if err != nil { return "", err } - req, err := http.NewRequest("GET", apiEndpoint.String(), nil) + req, err := client.NewRequest(ctx, http.MethodGet, apiEndpoint.String(), nil, githubrest.WithSingleRequestToken(authToken)) if err != nil { return "", err } - req.Header.Set("Authorization", "token "+authToken) - - res, err := httpClient.Do(req) + // Send rather than Do, because the answer is in a response header. Do + // closes the body; here it is drained first so the connection is reused + // for the requests that follow. + res, err := client.Send(req) if err != nil { + if res != nil { + res.Body.Close() + } return "", err } - defer func() { - // Ensure the response body is fully read and closed - // before we reconnect, so that we reuse the same TCPconnection. _, _ = io.Copy(io.Discard, res.Body) res.Body.Close() }() - if res.StatusCode != 200 { - return "", api.HandleHTTPError(res) - } - return res.Header.Get("X-Oauth-Scopes"), nil } // HasMinimumScopes performs a GitHub API request and returns an error if the token used in the request // lacks the minimum required scopes for performing API operations with gh. -func HasMinimumScopes(httpClient httpClient, hostname, authToken string) error { - scopesHeader, err := GetScopes(httpClient, hostname, authToken) +func HasMinimumScopes(ctx context.Context, client *githubrest.Client, hostname, authToken string) error { + scopesHeader, err := GetScopes(ctx, client, hostname, authToken) if err != nil { return err } diff --git a/pkg/cmd/auth/shared/oauth_scopes_test.go b/pkg/cmd/auth/shared/oauth_scopes_test.go index b1ea4c6014a..4873903c231 100644 --- a/pkg/cmd/auth/shared/oauth_scopes_test.go +++ b/pkg/cmd/auth/shared/oauth_scopes_test.go @@ -2,12 +2,14 @@ package shared import ( "bytes" + "context" "io" "net/http" "testing" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_HasMinimumScopes(t *testing.T) { @@ -45,8 +47,10 @@ func Test_HasMinimumScopes(t *testing.T) { }, nil }) - client := http.Client{Transport: fakehttp} - err := HasMinimumScopes(&client, "github.com", "ATOKEN") + client, err := httpmock.RESTClientFuncAnonymous(fakehttp)("github.com") + require.NoError(t, err) + + err = HasMinimumScopes(context.Background(), client, "github.com", "ATOKEN") if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) } else { diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index f1c597bbac9..4000aed4b64 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -1,6 +1,7 @@ package status import ( + "context" "errors" "fmt" "net" @@ -13,6 +14,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/auth/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -118,10 +120,11 @@ func (e authEntry) String(cs *iostreams.ColorScheme) string { } type StatusOptions struct { - HttpClient func() (*http.Client, error) - IO *iostreams.IOStreams - Config func() (gh.Config, error) - Exporter cmdutil.Exporter + HttpClient func() (*http.Client, error) + GitHubRESTAnonymous func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) + IO *iostreams.IOStreams + Config func() (gh.Config, error) + Exporter cmdutil.Exporter Hostname string ShowToken bool @@ -130,9 +133,10 @@ type StatusOptions struct { func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { opts := &StatusOptions{ - HttpClient: f.HttpClient, - IO: f.IOStreams, - Config: f.Config, + HttpClient: f.HttpClient, + GitHubRESTAnonymous: f.GitHubRESTAnonymous, + IO: f.IOStreams, + Config: f.Config, } cmd := &cobra.Command{ @@ -175,7 +179,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co return runF(opts) } - return statusRun(opts) + return statusRun(cmd.Context(), opts) }, } @@ -189,7 +193,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co return cmd } -func statusRun(opts *StatusOptions) error { +func statusRun(ctx context.Context, opts *StatusOptions) error { cfg, err := opts.Config() if err != nil { return err @@ -242,7 +246,14 @@ func statusRun(opts *StatusOptions) error { if authTokenWriteable(activeUserTokenSource) { activeUser, _ = authCfg.ActiveUser(hostname) } - entry := buildEntry(httpClient, buildEntryOptions{ + // An anonymous client per host, because status reports on every token + // configured for that host rather than on the active one. + restClient, err := opts.GitHubRESTAnonymous(hostname) + if err != nil { + return err + } + + entry := buildEntry(ctx, httpClient, restClient, buildEntryOptions{ active: true, gitProtocol: gitProtocol, hostname: hostname, @@ -266,7 +277,14 @@ func statusRun(opts *StatusOptions) error { continue } token, tokenSource, _ := authCfg.TokenForUser(hostname, username) - entry := buildEntry(httpClient, buildEntryOptions{ + // An anonymous client per host, because status reports on every token + // configured for that host rather than on the active one. + restClient, err := opts.GitHubRESTAnonymous(hostname) + if err != nil { + return err + } + + entry := buildEntry(ctx, httpClient, restClient, buildEntryOptions{ active: false, gitProtocol: gitProtocol, hostname: hostname, @@ -368,7 +386,7 @@ type buildEntryOptions struct { username string } -func buildEntry(httpClient *http.Client, opts buildEntryOptions) authEntry { +func buildEntry(ctx context.Context, httpClient *http.Client, restClient *githubrest.Client, opts buildEntryOptions) authEntry { tokenSource := opts.tokenSource if tokenSource == "oauth_token" { // The go-gh function TokenForHost returns this value as source for tokens read from the @@ -400,7 +418,7 @@ func buildEntry(httpClient *http.Client, opts buildEntryOptions) authEntry { } // Get scopes for token. - scopesHeader, err := shared.GetScopes(httpClient, opts.hostname, opts.token) + scopesHeader, err := shared.GetScopes(ctx, restClient, opts.hostname, opts.token) if err != nil { var networkError net.Error if errors.As(err, &networkError) && networkError.Timeout() { diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 87ee5f5e5a3..d317e2b82cc 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -731,6 +731,7 @@ func Test_statusRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubRESTAnonymous = httpmock.RESTClientFuncAnonymous(reg) if tt.httpStubs != nil { tt.httpStubs(reg) } @@ -745,7 +746,7 @@ func Test_statusRun(t *testing.T) { t.Setenv(k, v) } - err := statusRun(&tt.opts) + err := statusRun(context.Background(), &tt.opts) if tt.wantErr != nil { require.Equal(t, err, tt.wantErr) } else { From b0bc7f249a20d401ab0386fd9e25f8f1e24bf714 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 14:49:55 +0200 Subject: [PATCH 12/21] Route search through githubrest The searcher holds a client rather than an http.Client, and gh extension browse's day-long response cache becomes a client-level default request option instead of a wrapped transport. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- internal/githubrest/client.go | 20 +++++++++++++ pkg/cmd/extension/browse/browse_test.go | 8 +++-- pkg/cmd/extension/command.go | 14 +++++++-- pkg/cmd/extension/command_test.go | 1 + pkg/cmd/search/code/code.go | 2 +- pkg/cmd/search/code/code_test.go | 7 +++-- pkg/cmd/search/commits/commits.go | 2 +- pkg/cmd/search/issues/issues.go | 2 +- pkg/cmd/search/prs/prs.go | 2 +- pkg/cmd/search/repos/repos.go | 2 +- pkg/cmd/search/shared/shared.go | 10 +++++-- pkg/cmd/search/shared/shared_test.go | 5 +++- pkg/search/searcher.go | 40 ++++++++++++++++--------- pkg/search/searcher_test.go | 32 ++++++++++++-------- 14 files changed, 103 insertions(+), 44 deletions(-) diff --git a/internal/githubrest/client.go b/internal/githubrest/client.go index b520dcac966..5287443f2f4 100644 --- a/internal/githubrest/client.go +++ b/internal/githubrest/client.go @@ -53,6 +53,9 @@ type Client struct { // credentialedHosts are the hosts this client will send an Authorization // header to, lowercased and including any port. credentialedHosts map[string]struct{} + + // defaultRequestOptions are applied to every request the client builds. + defaultRequestOptions []RequestOption } // AuthStrategy states whether a client authenticates, and is a required @@ -123,6 +126,19 @@ func WithCheckRedirect(fn func(*http.Request, []*http.Request) error) ClientOpti } } +// WithDefaultRequestOptions applies opts to every request the client builds, +// before the request's own options, so a call site can still override them. +// +// This exists for options that describe the client's whole job rather than one +// request. Caching is the case that exists: gh extension browse wants every +// search response served from cache for a day, and the searcher it hands the +// client to builds the requests itself. +func WithDefaultRequestOptions(opts ...RequestOption) ClientOption { + return func(c *Client) { + c.defaultRequestOptions = append(c.defaultRequestOptions, opts...) + } +} + // NewClient returns a Client that sends requests to apiBaseURL through // httpClient, authenticating as auth describes. // @@ -261,6 +277,10 @@ func (c *Client) NewRequest(ctx context.Context, method, path string, body io.Re req.Header.Set(authorizationHeader, authorizationValue(token)) } + for _, opt := range c.defaultRequestOptions { + opt(req) + } + for _, opt := range opts { opt(req) } diff --git a/pkg/cmd/extension/browse/browse_test.go b/pkg/cmd/extension/browse/browse_test.go index 8120da4d32a..126b0592a38 100644 --- a/pkg/cmd/extension/browse/browse_test.go +++ b/pkg/cmd/extension/browse/browse_test.go @@ -1,6 +1,7 @@ package browse import ( + "context" "encoding/base64" "io" "log" @@ -20,6 +21,7 @@ import ( "github.com/cli/cli/v2/pkg/search" "github.com/rivo/tview" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_getSelectedReadme(t *testing.T) { @@ -69,8 +71,6 @@ func Test_getExtensionRepos(t *testing.T) { reg := httpmock.Registry{} defer reg.Verify(t) - client := &http.Client{Transport: ®} - values := url.Values{ "page": []string{"1"}, "per_page": []string{"100"}, @@ -126,7 +126,9 @@ func Test_getExtensionRepos(t *testing.T) { }), ) - searcher := search.NewSearcher(client, "github.com", &fd.DisabledDetectorMock{}) + restClient, err := httpmock.RESTClientFunc(®)("github.com") + require.NoError(t, err) + searcher := search.NewSearcher(context.Background(), restClient, "github.com", &fd.DisabledDetectorMock{}) emMock := &extensions.ExtensionManagerMock{} emMock.ListFunc = func() []extensions.Extension { return []extensions.Extension{ diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 6ec516533d0..a82c10150bb 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -10,10 +10,10 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/extension/browse" @@ -170,7 +170,11 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { host, _ := cfg.Authentication().DefaultHost() detector := featuredetection.NewDetector(client, host) - searcher := search.NewSearcher(client, host, detector) + restClient, err := f.GitHubREST(host) + if err != nil { + return err + } + searcher := search.NewSearcher(cmd.Context(), restClient, host, detector) if webMode { url := searcher.URL(query) @@ -516,7 +520,11 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { } detector := featuredetection.NewDetector(client, host) - searcher := search.NewSearcher(api.NewCachedHTTPClient(client, time.Hour*24), host, detector) + restClient, err := f.GitHubREST(host, githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + searcher := search.NewSearcher(cmd.Context(), restClient, host, detector) gc.Stderr = gio.Discard diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index 7001c8f1a7a..890686281b0 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -942,6 +942,7 @@ func TestNewCmdExtension(t *testing.T) { HttpClient: func() (*http.Client, error) { return &client, nil }, + GitHubREST: httpmock.RESTClientFunc(®), } cmd := NewCmdExtension(&f) diff --git a/pkg/cmd/search/code/code.go b/pkg/cmd/search/code/code.go index 734ba8ff869..0fecb347b1f 100644 --- a/pkg/cmd/search/code/code.go +++ b/pkg/cmd/search/code/code.go @@ -83,7 +83,7 @@ func NewCmdCode(f *cmdutil.Factory, runF func(*CodeOptions) error) *cobra.Comman return runF(opts) } var err error - opts.Searcher, err = shared.Searcher(f) + opts.Searcher, err = shared.Searcher(c.Context(), f) if err != nil { return err } diff --git a/pkg/cmd/search/code/code_test.go b/pkg/cmd/search/code/code_test.go index 471a9d0cb3f..4ceca262f92 100644 --- a/pkg/cmd/search/code/code_test.go +++ b/pkg/cmd/search/code/code_test.go @@ -2,6 +2,7 @@ package code import ( "bytes" + "context" "fmt" "testing" @@ -337,7 +338,7 @@ func TestCodeRun(t *testing.T) { Extension: "go", }, }, - Searcher: search.NewSearcher(nil, "github.com", &fd.DisabledDetectorMock{}), + Searcher: search.NewSearcher(context.Background(), nil, "github.com", &fd.DisabledDetectorMock{}), WebMode: true, }, wantBrowse: "https://github.com/search?q=map+path%3Atesting.go&type=code", @@ -355,7 +356,7 @@ func TestCodeRun(t *testing.T) { Extension: ".cpp", }, }, - Searcher: search.NewSearcher(nil, "github.com", &fd.DisabledDetectorMock{}), + Searcher: search.NewSearcher(context.Background(), nil, "github.com", &fd.DisabledDetectorMock{}), WebMode: true, }, wantBrowse: "https://github.com/search?q=map+path%3Atesting.cpp&type=code", @@ -382,7 +383,7 @@ func TestCodeRun(t *testing.T) { Extension: "go", }, }, - Searcher: search.NewSearcher(nil, "example.com", &fd.DisabledDetectorMock{}), + Searcher: search.NewSearcher(context.Background(), nil, "example.com", &fd.DisabledDetectorMock{}), WebMode: true, }, wantBrowse: "https://example.com/search?q=map+extension%3Ago+filename%3Atesting&type=code", diff --git a/pkg/cmd/search/commits/commits.go b/pkg/cmd/search/commits/commits.go index f07a9077683..7cc349275a0 100644 --- a/pkg/cmd/search/commits/commits.go +++ b/pkg/cmd/search/commits/commits.go @@ -88,7 +88,7 @@ func NewCmdCommits(f *cmdutil.Factory, runF func(*CommitsOptions) error) *cobra. return runF(opts) } var err error - opts.Searcher, err = shared.Searcher(f) + opts.Searcher, err = shared.Searcher(c.Context(), f) if err != nil { return err } diff --git a/pkg/cmd/search/issues/issues.go b/pkg/cmd/search/issues/issues.go index 409cbf09b35..d9acf33e63e 100644 --- a/pkg/cmd/search/issues/issues.go +++ b/pkg/cmd/search/issues/issues.go @@ -115,7 +115,7 @@ func NewCmdIssues(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *c return runF(opts) } var err error - opts.Searcher, err = shared.Searcher(f) + opts.Searcher, err = shared.Searcher(c.Context(), f) if err != nil { return err } diff --git a/pkg/cmd/search/prs/prs.go b/pkg/cmd/search/prs/prs.go index 5f193165216..070d2606ece 100644 --- a/pkg/cmd/search/prs/prs.go +++ b/pkg/cmd/search/prs/prs.go @@ -127,7 +127,7 @@ func NewCmdPrs(f *cmdutil.Factory, runF func(*shared.IssuesOptions) error) *cobr return runF(opts) } var err error - opts.Searcher, err = shared.Searcher(f) + opts.Searcher, err = shared.Searcher(c.Context(), f) if err != nil { return err } diff --git a/pkg/cmd/search/repos/repos.go b/pkg/cmd/search/repos/repos.go index 254de8a12c5..878b3c8caf3 100644 --- a/pkg/cmd/search/repos/repos.go +++ b/pkg/cmd/search/repos/repos.go @@ -92,7 +92,7 @@ func NewCmdRepos(f *cmdutil.Factory, runF func(*ReposOptions) error) *cobra.Comm return runF(opts) } var err error - opts.Searcher, err = shared.Searcher(f) + opts.Searcher, err = shared.Searcher(c.Context(), f) if err != nil { return err } diff --git a/pkg/cmd/search/shared/shared.go b/pkg/cmd/search/shared/shared.go index 1989bb9d323..9d4c73bbd29 100644 --- a/pkg/cmd/search/shared/shared.go +++ b/pkg/cmd/search/shared/shared.go @@ -1,6 +1,7 @@ package shared import ( + "context" "fmt" "strconv" "strings" @@ -38,7 +39,7 @@ type IssuesOptions struct { WebMode bool } -func Searcher(f *cmdutil.Factory) (search.Searcher, error) { +func Searcher(ctx context.Context, f *cmdutil.Factory) (search.Searcher, error) { cfg, err := f.Config() if err != nil { return nil, err @@ -52,7 +53,12 @@ func Searcher(f *cmdutil.Factory) (search.Searcher, error) { detector := fd.NewDetector(client, host) - return search.NewSearcher(client, host, detector), nil + restClient, err := f.GitHubREST(host) + if err != nil { + return nil, err + } + + return search.NewSearcher(ctx, restClient, host, detector), nil } func SearchIssues(opts *IssuesOptions) error { diff --git a/pkg/cmd/search/shared/shared_test.go b/pkg/cmd/search/shared/shared_test.go index bd8060943c2..153be809975 100644 --- a/pkg/cmd/search/shared/shared_test.go +++ b/pkg/cmd/search/shared/shared_test.go @@ -1,6 +1,7 @@ package shared import ( + "context" "fmt" "net/http" "testing" @@ -10,6 +11,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/search" "github.com/stretchr/testify/assert" @@ -23,8 +25,9 @@ func TestSearcher(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, + GitHubREST: httpmock.RESTClientFunc(&httpmock.Registry{}), } - _, err := Searcher(f) + _, err := Searcher(context.Background(), f) assert.NoError(t, err) } diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go index dd8dd590cac..f3614819f60 100644 --- a/pkg/search/searcher.go +++ b/pkg/search/searcher.go @@ -1,6 +1,7 @@ package search import ( + "context" "encoding/json" "fmt" "io" @@ -12,6 +13,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -36,7 +38,11 @@ type Searcher interface { } type searcher struct { - client *http.Client + // ctx is held rather than passed, because the Searcher interface is + // implemented against per-query methods that the CLI's search commands + // call without one. + ctx context.Context + client *githubrest.Client detector fd.Detector host string } @@ -55,8 +61,9 @@ type httpErrorItem struct { Resource string } -func NewSearcher(client *http.Client, host string, detector fd.Detector) Searcher { +func NewSearcher(ctx context.Context, client *githubrest.Client, host string, detector fd.Detector) Searcher { return &searcher{ + ctx: ctx, client: client, host: host, detector: detector, @@ -236,28 +243,33 @@ func (s searcher) search(query Query, result interface{}) (string, error) { if query.Sort != "" { u.SetQuery(sortKey, query.Sort) } - req, err := http.NewRequest("GET", u.String(), nil) - if err != nil { - return "", err - } - req.Header.Set("Content-Type", "application/json; charset=utf-8") - req.Header.Set("Accept", "application/vnd.github.v3+json") + accept := "application/vnd.github.v3+json" if query.Kind == KindCode { - req.Header.Set("Accept", "application/vnd.github.text-match+json") + accept = "application/vnd.github.text-match+json" } - resp, err := s.client.Do(req) + req, err := s.client.NewRequest(s.ctx, http.MethodGet, u.String(), nil, + githubrest.WithHeader("Content-Type", "application/json; charset=utf-8"), + githubrest.WithHeader("Accept", accept)) if err != nil { return "", err } + + // Send rather than Do, because pagination reads the Link header, and + // because search reports an invalid query as a 422 that this package + // renders specially. + resp, err := s.client.Send(req) + if err != nil { + if resp == nil { + return "", err + } + defer resp.Body.Close() + return resp.Header.Get("Link"), handleHTTPError(resp.Response) + } defer resp.Body.Close() link := resp.Header.Get("Link") - success := resp.StatusCode >= 200 && resp.StatusCode < 300 - if !success { - return link, handleHTTPError(resp) - } decoder := json.NewDecoder(resp.Body) err = decoder.Decode(result) if err != nil { diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go index 9ed40322e13..579c08e407f 100644 --- a/pkg/search/searcher_test.go +++ b/pkg/search/searcher_test.go @@ -1,8 +1,8 @@ package search import ( + "context" "fmt" - "net/http" "net/url" "strconv" "testing" @@ -11,6 +11,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestSearcherCode(t *testing.T) { @@ -262,11 +263,12 @@ func TestSearcherCode(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - client := &http.Client{Transport: reg} if tt.host == "" { tt.host = "github.com" } - searcher := NewSearcher(client, tt.host, &fd.DisabledDetectorMock{}) + client, err := httpmock.RESTClientFunc(reg)(tt.host) + require.NoError(t, err) + searcher := NewSearcher(context.Background(), client, tt.host, &fd.DisabledDetectorMock{}) result, err := searcher.Code(tt.query) if tt.wantErr { assert.EqualError(t, err, tt.errMsg) @@ -548,11 +550,12 @@ func TestSearcherCommits(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - client := &http.Client{Transport: reg} if tt.host == "" { tt.host = "github.com" } - searcher := NewSearcher(client, tt.host, &fd.DisabledDetectorMock{}) + client, err := httpmock.RESTClientFunc(reg)(tt.host) + require.NoError(t, err) + searcher := NewSearcher(context.Background(), client, tt.host, &fd.DisabledDetectorMock{}) result, err := searcher.Commits(tt.query) if tt.wantErr { assert.EqualError(t, err, tt.errMsg) @@ -834,11 +837,12 @@ func TestSearcherRepositories(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - client := &http.Client{Transport: reg} if tt.host == "" { tt.host = "github.com" } - searcher := NewSearcher(client, tt.host, &fd.DisabledDetectorMock{}) + client, err := httpmock.RESTClientFunc(reg)(tt.host) + require.NoError(t, err) + searcher := NewSearcher(context.Background(), client, tt.host, &fd.DisabledDetectorMock{}) result, err := searcher.Repositories(tt.query) if tt.wantErr { assert.EqualError(t, err, tt.errMsg) @@ -1120,11 +1124,12 @@ func TestSearcherIssues(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - client := &http.Client{Transport: reg} if tt.host == "" { tt.host = "github.com" } - searcher := NewSearcher(client, tt.host, fd.AdvancedIssueSearchUnsupported()) + client, err := httpmock.RESTClientFunc(reg)(tt.host) + require.NoError(t, err) + searcher := NewSearcher(context.Background(), client, tt.host, fd.AdvancedIssueSearchUnsupported()) result, err := searcher.Issues(tt.query) if tt.wantErr { assert.EqualError(t, err, tt.errMsg) @@ -1205,10 +1210,11 @@ func TestSearcherIssuesAdvancedSyntax(t *testing.T) { httpmock.JSONResponse(IssuesResult{}), ) - client := &http.Client{Transport: reg} - searcher := NewSearcher(client, "github.com", tt.detector) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + searcher := NewSearcher(context.Background(), client, "github.com", tt.detector) - _, err := searcher.Issues(tt.query) + _, err = searcher.Issues(tt.query) if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) } else { @@ -1262,7 +1268,7 @@ func TestSearcherURL(t *testing.T) { if tt.host == "" { tt.host = "github.com" } - searcher := NewSearcher(nil, tt.host, nil) + searcher := NewSearcher(context.Background(), nil, tt.host, nil) assert.Equal(t, tt.url, searcher.URL(tt.query)) }) } From 8f00eeb0c0a645cddfe9176224f62e235fb99dc9 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 14:55:13 +0200 Subject: [PATCH 13/21] Route attestation and the update check through githubrest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- internal/ghcmd/cmd.go | 7 ++- internal/update/update.go | 24 +++------- internal/update/update_test.go | 7 ++- pkg/cmd/attestation/api/client.go | 6 +-- pkg/cmd/attestation/api/client_test.go | 7 ++- pkg/cmd/attestation/api/rest_client.go | 48 +++++++++++++++++++ pkg/cmd/attestation/download/download.go | 12 ++--- pkg/cmd/attestation/download/download_test.go | 2 + pkg/cmd/attestation/inspect/inspect.go | 4 +- .../attestation/trustedroot/trustedroot.go | 4 +- .../trustedroot/trustedroot_test.go | 3 ++ pkg/cmd/attestation/verify/verify.go | 12 ++--- pkg/cmd/attestation/verify/verify_test.go | 1 + pkg/cmd/release/verify-asset/verify_asset.go | 2 +- pkg/cmd/release/verify/verify.go | 2 +- 15 files changed, 94 insertions(+), 47 deletions(-) create mode 100644 pkg/cmd/attestation/api/rest_client.go diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 10179bf15b3..ee56b188607 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -325,12 +325,15 @@ func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion stri if updaterEnabled == "" || !update.ShouldCheckForUpdate() { return nil, nil } - httpClient, err := f.HttpClient() + // Anonymous, because the release check reads a public repository and runs + // alongside whatever command the user actually asked for; sending their + // token would spend rate limit on it for no benefit. + client, err := f.GitHubRESTAnonymous("github.com") if err != nil { return nil, err } stateFilePath := filepath.Join(config.StateDir(), "state.yml") - return update.CheckForUpdate(ctx, httpClient, stateFilePath, updaterEnabled, currentVersion) + return update.CheckForUpdate(ctx, client, stateFilePath, updaterEnabled, currentVersion) } func isRecentRelease(publishedAt time.Time) bool { diff --git a/internal/update/update.go b/internal/update/update.go index 27a7d8a248f..37d9682268e 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -2,9 +2,7 @@ package update import ( "context" - "encoding/json" "fmt" - "io" "net/http" "os" "path/filepath" @@ -14,6 +12,7 @@ import ( "time" "github.com/cli/cli/v2/internal/ci" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/extensions" "github.com/hashicorp/go-version" @@ -89,7 +88,7 @@ func ShouldCheckForUpdate() bool { } // CheckForUpdate checks whether an update exists for the GitHub CLI based on recency of last check within past 24 hours. -func CheckForUpdate(ctx context.Context, client *http.Client, stateFilePath, repo, currentVersion string) (*ReleaseInfo, error) { +func CheckForUpdate(ctx context.Context, client *githubrest.Client, stateFilePath, repo, currentVersion string) (*ReleaseInfo, error) { stateEntry, _ := getStateEntry(stateFilePath) if stateEntry != nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 { return nil, nil @@ -112,7 +111,7 @@ func CheckForUpdate(ctx context.Context, client *http.Client, stateFilePath, rep return nil, nil } -func getLatestReleaseInfo(ctx context.Context, client *http.Client, repo string) (*ReleaseInfo, error) { +func getLatestReleaseInfo(ctx context.Context, client *githubrest.Client, repo string) (*ReleaseInfo, error) { owner, name, err := safeurl.RepoPartsFromNWO(repo) if err != nil { return nil, err @@ -121,24 +120,13 @@ func getLatestReleaseInfo(ctx context.Context, client *http.Client, repo string) if err != nil { return nil, err } - req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } - res, err := client.Do(req) - if err != nil { - return nil, err - } - defer func() { - _, _ = io.Copy(io.Discard, res.Body) - res.Body.Close() - }() - if res.StatusCode != 200 { - return nil, fmt.Errorf("unexpected HTTP %d", res.StatusCode) - } - dec := json.NewDecoder(res.Body) + var latestRelease ReleaseInfo - if err := dec.Decode(&latestRelease); err != nil { + if _, err := client.Do(req, &latestRelease); err != nil { return nil, err } return &latestRelease, nil diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 089e36f6885..62c0156bb0a 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "log" - "net/http" "os" "path/filepath" "testing" @@ -78,8 +77,8 @@ func TestCheckForUpdate(t *testing.T) { for _, s := range scenarios { t.Run(s.Name, func(t *testing.T) { reg := &httpmock.Registry{} - httpClient := &http.Client{} - httpmock.ReplaceTripper(httpClient, reg) + client, err := httpmock.RESTClientFuncAnonymous(reg)("github.com") + require.NoError(t, err) reg.Register( httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"), @@ -89,7 +88,7 @@ func TestCheckForUpdate(t *testing.T) { }`, s.LatestVersion, s.LatestURL)), ) - rel, err := CheckForUpdate(context.TODO(), httpClient, tempFilePath(), "OWNER/REPO", s.CurrentVersion) + rel, err := CheckForUpdate(context.TODO(), client, tempFilePath(), "OWNER/REPO", s.CurrentVersion) if err != nil { t.Fatal(err) } diff --git a/pkg/cmd/attestation/api/client.go b/pkg/cmd/attestation/api/client.go index 745da03ee16..a9f989f8899 100644 --- a/pkg/cmd/attestation/api/client.go +++ b/pkg/cmd/attestation/api/client.go @@ -1,6 +1,7 @@ package api import ( + "context" "errors" "fmt" "io" @@ -10,7 +11,6 @@ import ( "time" "github.com/cenkalti/backoff/v4" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ioconfig "github.com/cli/cli/v2/pkg/cmd/attestation/io" @@ -76,9 +76,9 @@ type LiveClient struct { logger *ioconfig.Handler } -func NewLiveClient(hc *http.Client, externalClient *http.Client, host string, l *ioconfig.Handler) *LiveClient { +func NewLiveClient(ctx context.Context, restClient *githubrest.Client, externalClient *http.Client, host string, l *ioconfig.Handler) *LiveClient { return &LiveClient{ - githubAPI: api.NewClientFromHTTP(hc), + githubAPI: restAPIClient{ctx: ctx, client: restClient}, host: strings.TrimSuffix(host, "/"), externalHttpClient: externalClient, logger: l, diff --git a/pkg/cmd/attestation/api/client_test.go b/pkg/cmd/attestation/api/client_test.go index 4bb7c493b0a..8774d5d49e7 100644 --- a/pkg/cmd/attestation/api/client_test.go +++ b/pkg/cmd/attestation/api/client_test.go @@ -1,10 +1,10 @@ package api import ( + "context" "net/http" "testing" - cliAPI "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/test/data" @@ -404,8 +404,11 @@ func TestGetAttestationsRetriesRESTWithNextError(t *testing.T) { }), ) + restClient, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + c := &LiveClient{ - githubAPI: cliAPI.NewClientFromHTTP(&http.Client{Transport: reg}), + githubAPI: restAPIClient{ctx: context.Background(), client: restClient}, host: "github.com", logger: io.NewTestHandler(), } diff --git a/pkg/cmd/attestation/api/rest_client.go b/pkg/cmd/attestation/api/rest_client.go new file mode 100644 index 00000000000..13c7487feb8 --- /dev/null +++ b/pkg/cmd/attestation/api/rest_client.go @@ -0,0 +1,48 @@ +package api + +import ( + "context" + "encoding/json" + "io" + + "github.com/cli/cli/v2/internal/githubrest" +) + +// restAPIClient adapts a githubrest.Client to the githubApiClient interface +// this package already retries and mocks against. +// +// The hostname argument is ignored: the client is built for one host at +// construction. It stays in the signature because the interface is what the +// package's test doubles implement. +type restAPIClient struct { + ctx context.Context + client *githubrest.Client +} + +func (c restAPIClient) REST(hostname, method, p string, body io.Reader, data interface{}) error { + _, err := c.RESTWithNext(hostname, method, p, body, data) + return err +} + +func (c restAPIClient) RESTWithNext(hostname, method, p string, body io.Reader, data interface{}) (string, error) { + req, err := c.client.NewRequest(c.ctx, method, p, body) + if err != nil { + return "", err + } + + // Send rather than Do, because attestations are paginated by Link header. + resp, err := c.client.Send(req) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return "", err + } + defer resp.Body.Close() + + if err := json.NewDecoder(resp.Body).Decode(data); err != nil { + return "", err + } + + return resp.NextPage(), nil +} diff --git a/pkg/cmd/attestation/download/download.go b/pkg/cmd/attestation/download/download.go index f0024018044..e1d5a7952d2 100644 --- a/pkg/cmd/attestation/download/download.go +++ b/pkg/cmd/attestation/download/download.go @@ -79,11 +79,6 @@ func NewDownloadCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Comman return nil }, RunE: func(cmd *cobra.Command, args []string) error { - hc, err := f.HttpClient() - if err != nil { - return err - } - externalClient, err := f.ExternalHttpClient() if err != nil { return err @@ -96,7 +91,12 @@ func NewDownloadCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Comman return err } - opts.APIClient = api.NewLiveClient(hc, externalClient, opts.Hostname, opts.Logger) + restClient, err := f.GitHubREST(opts.Hostname) + if err != nil { + return err + } + + opts.APIClient = api.NewLiveClient(cmd.Context(), restClient, externalClient, opts.Hostname, opts.Logger) opts.OCIClient = oci.NewLiveClient() opts.Store = NewLiveStore("") diff --git a/pkg/cmd/attestation/download/download_test.go b/pkg/cmd/attestation/download/download_test.go index d470c7afbce..7c64a7674ad 100644 --- a/pkg/cmd/attestation/download/download_test.go +++ b/pkg/cmd/attestation/download/download_test.go @@ -14,6 +14,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/attestation/io" "github.com/cli/cli/v2/pkg/cmd/attestation/test" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" @@ -43,6 +44,7 @@ func TestNewDownloadCmd(t *testing.T) { ExternalHttpClient: func() (*http.Client, error) { return nil, nil }, + GitHubREST: httpmock.RESTClientFunc(&httpmock.Registry{}), } store := &LiveStore{ diff --git a/pkg/cmd/attestation/inspect/inspect.go b/pkg/cmd/attestation/inspect/inspect.go index 97aa149fb56..7fea73f59be 100644 --- a/pkg/cmd/attestation/inspect/inspect.go +++ b/pkg/cmd/attestation/inspect/inspect.go @@ -81,7 +81,7 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command return err } - hc, err := f.HttpClient() + restClient, err := f.GitHubREST(opts.Hostname) if err != nil { return err } @@ -97,7 +97,7 @@ func NewInspectCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command } if ghauth.IsTenancy(opts.Hostname) { - apiClient := api.NewLiveClient(hc, externalClient, opts.Hostname, opts.Logger) + apiClient := api.NewLiveClient(cmd.Context(), restClient, externalClient, opts.Hostname, opts.Logger) td, err := apiClient.GetTrustDomain() if err != nil { return fmt.Errorf("error getting trust domain, make sure you are authenticated against the host: %w", err) diff --git a/pkg/cmd/attestation/trustedroot/trustedroot.go b/pkg/cmd/attestation/trustedroot/trustedroot.go index 242ebcd1fb3..949a33c096b 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot.go @@ -67,7 +67,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com return err } - hc, err := f.HttpClient() + restClient, err := f.GitHubREST(opts.Hostname) if err != nil { return err } @@ -87,7 +87,7 @@ func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Com return fmt.Errorf("not authenticated with %s", opts.Hostname) } logger := io.NewHandler(f.IOStreams) - apiClient := api.NewLiveClient(hc, externalClient, opts.Hostname, logger) + apiClient := api.NewLiveClient(cmd.Context(), restClient, externalClient, opts.Hostname, logger) td, err := apiClient.GetTrustDomain() if err != nil { return err diff --git a/pkg/cmd/attestation/trustedroot/trustedroot_test.go b/pkg/cmd/attestation/trustedroot/trustedroot_test.go index 02457a42d80..536c08f951b 100644 --- a/pkg/cmd/attestation/trustedroot/trustedroot_test.go +++ b/pkg/cmd/attestation/trustedroot/trustedroot_test.go @@ -37,6 +37,7 @@ func TestNewTrustedRootCmd(t *testing.T) { ExternalHttpClient: func() (*http.Client, error) { return nil, nil }, + GitHubREST: httpmock.RESTClientFunc(&httpmock.Registry{}), } testcases := []struct { @@ -126,6 +127,7 @@ func TestNewTrustedRootWithTenancy(t *testing.T) { ExternalHttpClient: func() (*http.Client, error) { return nil, nil }, + GitHubREST: httpmock.RESTClientFunc(&testReg), } cmd := NewTrustedRootCmd(f, func(_ *Options) error { @@ -157,6 +159,7 @@ func TestNewTrustedRootWithTenancy(t *testing.T) { ExternalHttpClient: func() (*http.Client, error) { return nil, nil }, + GitHubREST: httpmock.RESTClientFunc(&testReg), } cmd := NewTrustedRootCmd(f, func(_ *Options) error { diff --git a/pkg/cmd/attestation/verify/verify.go b/pkg/cmd/attestation/verify/verify.go index 120f94d6588..9916391a80d 100644 --- a/pkg/cmd/attestation/verify/verify.go +++ b/pkg/cmd/attestation/verify/verify.go @@ -168,11 +168,6 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command return nil }, RunE: func(cmd *cobra.Command, args []string) error { - hc, err := f.HttpClient() - if err != nil { - return err - } - externalClient, err := f.ExternalHttpClient() if err != nil { return err @@ -188,7 +183,12 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command return err } - opts.APIClient = api.NewLiveClient(hc, externalClient, opts.Hostname, opts.Logger) + restClient, err := f.GitHubREST(opts.Hostname) + if err != nil { + return err + } + + opts.APIClient = api.NewLiveClient(cmd.Context(), restClient, externalClient, opts.Hostname, opts.Logger) config := verification.SigstoreConfig{ ExternalHttpClient: externalClient, diff --git a/pkg/cmd/attestation/verify/verify_test.go b/pkg/cmd/attestation/verify/verify_test.go index 295d4a30a30..2eb54c7a868 100644 --- a/pkg/cmd/attestation/verify/verify_test.go +++ b/pkg/cmd/attestation/verify/verify_test.go @@ -57,6 +57,7 @@ func TestNewVerifyCmd(t *testing.T) { ExternalHttpClient: func() (*http.Client, error) { return nil, nil }, + GitHubREST: httpmock.RESTClientFunc(&testReg), } testcases := []struct { diff --git a/pkg/cmd/release/verify-asset/verify_asset.go b/pkg/cmd/release/verify-asset/verify_asset.go index 5b1e069375c..bde3b5f0cb0 100644 --- a/pkg/cmd/release/verify-asset/verify_asset.go +++ b/pkg/cmd/release/verify-asset/verify_asset.go @@ -96,7 +96,7 @@ func NewCmdVerifyAsset(f *cmdutil.Factory, runF func(*VerifyAssetConfig) error) } io := f.IOStreams - attClient := api.NewLiveClient(httpClient, externalClient, baseRepo.RepoHost(), att_io.NewHandler(io)) + attClient := api.NewLiveClient(cmd.Context(), restClient, externalClient, baseRepo.RepoHost(), att_io.NewHandler(io)) attVerifier := &shared.AttestationVerifier{ AttClient: attClient, diff --git a/pkg/cmd/release/verify/verify.go b/pkg/cmd/release/verify/verify.go index cf45b88813c..c490c0a09cc 100644 --- a/pkg/cmd/release/verify/verify.go +++ b/pkg/cmd/release/verify/verify.go @@ -92,7 +92,7 @@ func NewCmdVerify(f *cmdutil.Factory, runF func(config *VerifyConfig) error) *co } io := f.IOStreams - attClient := api.NewLiveClient(httpClient, externalClient, baseRepo.RepoHost(), att_io.NewHandler(io)) + attClient := api.NewLiveClient(cmd.Context(), restClient, externalClient, baseRepo.RepoHost(), att_io.NewHandler(io)) attVerifier := &shared.AttestationVerifier{ AttClient: attClient, From 9dd5f2c2bb7e5d9c9ddd1baddb7092cc633f1cd0 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 14:59:15 +0200 Subject: [PATCH 14/21] Route codespaces through githubrest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- internal/codespaces/api/api.go | 141 +++++++++++++++++++--------- internal/codespaces/api/api_test.go | 70 +++++++++----- 2 files changed, 141 insertions(+), 70 deletions(-) diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index 29a852cb68f..9e0c4590621 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -40,8 +40,8 @@ import ( "time" "github.com/cenkalti/backoff/v4" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/opentracing/opentracing-go" @@ -61,7 +61,8 @@ const ( // API is the interface to the codespace service. type API struct { - client func() (*http.Client, error) + restClient *githubrest.Client + restClientErr error externalClient func() (*http.Client, error) githubAPI string githubServer string @@ -70,32 +71,44 @@ type API struct { // New creates a new API client connecting to the configured endpoints with the HTTP client. func New(f *cmdutil.Factory) *API { + // The default host is resolved once and shared by the endpoint defaults and + // the REST client, so that a single config lookup serves all three. + var host string + cfg, cfgErr := f.Config() + if cfgErr == nil { + host, _ = cfg.Authentication().DefaultHost() + } + apiURL := os.Getenv("GITHUB_API_URL") if apiURL == "" { - cfg, err := f.Config() - if err != nil { + if cfgErr != nil { // fallback to the default api endpoint apiURL = defaultAPIURL } else { - host, _ := cfg.Authentication().DefaultHost() apiURL = ghinstance.RESTPrefix(host) } } serverURL := os.Getenv("GITHUB_SERVER_URL") if serverURL == "" { - cfg, err := f.Config() - if err != nil { + if cfgErr != nil { // fallback to the default server endpoint serverURL = defaultServerURL } else { - host, _ := cfg.Authentication().DefaultHost() serverURL = ghinstance.HostPrefix(host) } } + // The client is resolved here rather than per request, but New has no error + // to return, so a failure is carried and surfaced by newRequest. + restClient, restClientErr := newRESTClient(f, host, apiURL) + if cfgErr != nil { + restClient, restClientErr = nil, cfgErr + } + return &API{ - client: f.HttpClient, + restClient: restClient, + restClientErr: restClientErr, externalClient: f.ExternalHttpClient, githubAPI: strings.TrimSuffix(apiURL, "/"), githubServer: strings.TrimSuffix(serverURL, "/"), @@ -103,6 +116,32 @@ func New(f *cmdutil.Factory) *API { } } +// newRESTClient builds the REST client for apiURL. +// +// apiURL can be overridden by GITHUB_API_URL, so its host is declared as +// credentialed explicitly: it is not necessarily the host the factory resolved +// the token for. +func newRESTClient(f *cmdutil.Factory, host, apiURL string) (*githubrest.Client, error) { + opts := []githubrest.ClientOption{} + if parsed, err := url.Parse(apiURL); err == nil && parsed.Host != "" { + opts = append(opts, githubrest.WithCredentialedHost(parsed.Host)) + } + + return f.GitHubREST(host, opts...) +} + +// newRequest builds a request through the package's REST client. +// +// It exists because New cannot report a client construction failure, and +// because every request in this file is built from an absolute URL derived +// from a.githubAPI rather than from a relative path. +func (a *API) newRequest(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) { + if a.restClientErr != nil { + return nil, a.restClientErr + } + return a.restClient.NewRequest(ctx, method, url, body) +} + // User represents a GitHub user. type User struct { Login string `json:"login"` @@ -120,7 +159,7 @@ func (a *API) GetUser(ctx context.Context) (*User, error) { if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodGet, u.String(), nil) + req, err := a.newRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -133,7 +172,7 @@ func (a *API) GetUser(ctx context.Context) (*User, error) { defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, api.HandleHTTPError(resp) + return nil, githubrest.NewErrorResponse(resp) } b, err := io.ReadAll(resp.Body) @@ -173,7 +212,7 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodGet, u.String(), nil) + req, err := a.newRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -186,7 +225,7 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, api.HandleHTTPError(resp) + return nil, githubrest.NewErrorResponse(resp) } b, err := io.ReadAll(resp.Body) @@ -425,7 +464,7 @@ func (a *API) ListCodespaces(ctx context.Context, opts ListCodespacesOptions) (c } for { - req, err := http.NewRequest(http.MethodGet, listURL.String(), nil) + req, err := a.newRequest(ctx, http.MethodGet, listURL.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -438,7 +477,7 @@ func (a *API) ListCodespaces(ctx context.Context, opts ListCodespacesOptions) (c defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, api.HandleHTTPError(resp) + return nil, githubrest.NewErrorResponse(resp) } var response struct { @@ -492,7 +531,7 @@ func (a *API) GetOrgMemberCodespace(ctx context.Context, orgName string, userNam var listURL safeurl.SafeURL = u for { - req, err := http.NewRequest(http.MethodGet, listURL.String(), nil) + req, err := a.newRequest(ctx, http.MethodGet, listURL.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -505,7 +544,7 @@ func (a *API) GetOrgMemberCodespace(ctx context.Context, orgName string, userNam defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, api.HandleHTTPError(resp) + return nil, githubrest.NewErrorResponse(resp) } var response struct { @@ -542,7 +581,8 @@ func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeCon if err != nil { return nil, err } - req, err := http.NewRequest( + req, err := a.newRequest( + ctx, http.MethodGet, u.String(), nil, @@ -565,7 +605,7 @@ func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeCon defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, api.HandleHTTPError(resp) + return nil, githubrest.NewErrorResponse(resp) } b, err := io.ReadAll(resp.Body) @@ -589,7 +629,8 @@ func (a *API) StartCodespace(ctx context.Context, codespaceName string) error { if err != nil { return nil, err } - req, err := http.NewRequest( + req, err := a.newRequest( + ctx, http.MethodPost, u.String(), nil, @@ -610,7 +651,7 @@ func (a *API) StartCodespace(ctx context.Context, codespaceName string) error { // 409 means the codespace is already running which we can safely ignore return nil } - return api.HandleHTTPError(resp) + return githubrest.NewErrorResponse(resp) } return nil @@ -632,7 +673,7 @@ func (a *API) StopCodespace(ctx context.Context, codespaceName string, orgName s return err } - req, err := http.NewRequest(http.MethodPost, stopURL.String(), nil) + req, err := a.newRequest(ctx, http.MethodPost, stopURL.String(), nil) if err != nil { return fmt.Errorf("error creating request: %w", err) } @@ -645,7 +686,7 @@ func (a *API) StopCodespace(ctx context.Context, codespaceName string, orgName s defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return api.HandleHTTPError(resp) + return githubrest.NewErrorResponse(resp) } return nil @@ -663,7 +704,7 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int64, branch, l if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodGet, u.String(), nil) + req, err := a.newRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -682,7 +723,7 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int64, branch, l defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, api.HandleHTTPError(resp) + return nil, githubrest.NewErrorResponse(resp) } b, err := io.ReadAll(resp.Body) @@ -706,7 +747,7 @@ func (a *API) GetCodespacesPermissionsCheck(ctx context.Context, repoID int64, b if err != nil { return false, err } - req, err := http.NewRequest(http.MethodGet, u.String(), nil) + req, err := a.newRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { return false, fmt.Errorf("error creating request: %w", err) } @@ -724,7 +765,7 @@ func (a *API) GetCodespacesPermissionsCheck(ctx context.Context, repoID int64, b defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return false, api.HandleHTTPError(resp) + return false, githubrest.NewErrorResponse(resp) } b, err := io.ReadAll(resp.Body) @@ -756,7 +797,7 @@ func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch str if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodGet, reqURL.String(), nil) + req, err := a.newRequest(ctx, http.MethodGet, reqURL.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -800,7 +841,7 @@ func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch str defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, api.HandleHTTPError(resp) + return nil, githubrest.NewErrorResponse(resp) } b, err := io.ReadAll(resp.Body) @@ -834,7 +875,7 @@ func (a *API) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*User, if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodGet, u.String(), nil) + req, err := a.newRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -851,7 +892,7 @@ func (a *API) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*User, } else if resp.StatusCode == http.StatusForbidden { return nil, fmt.Errorf("you cannot create codespaces with that repository") } else if resp.StatusCode != http.StatusOK { - return nil, api.HandleHTTPError(resp) + return nil, githubrest.NewErrorResponse(resp) } b, err := io.ReadAll(resp.Body) @@ -983,7 +1024,7 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (* if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewBuffer(requestBody)) + req, err := a.newRequest(ctx, http.MethodPost, u.String(), bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -1028,10 +1069,10 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (* resp.Body = io.NopCloser(bodyCopy) - return nil, api.HandleHTTPError(resp) + return nil, githubrest.NewErrorResponse(resp) } else if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - return nil, api.HandleHTTPError(resp) + return nil, githubrest.NewErrorResponse(resp) } b, err := io.ReadAll(resp.Body) @@ -1064,7 +1105,7 @@ func (a *API) DeleteCodespace(ctx context.Context, codespaceName string, orgName return err } - req, err := http.NewRequest(http.MethodDelete, deleteURL.String(), nil) + req, err := a.newRequest(ctx, http.MethodDelete, deleteURL.String(), nil) if err != nil { return fmt.Errorf("error creating request: %w", err) } @@ -1077,7 +1118,7 @@ func (a *API) DeleteCodespace(ctx context.Context, codespaceName string, orgName defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { - return api.HandleHTTPError(resp) + return githubrest.NewErrorResponse(resp) } return nil @@ -1107,7 +1148,7 @@ func (a *API) ListDevContainers(ctx context.Context, repoID int64, branch string var listURL safeurl.SafeURL = u for { - req, err := http.NewRequest(http.MethodGet, listURL.String(), nil) + req, err := a.newRequest(ctx, http.MethodGet, listURL.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -1120,7 +1161,7 @@ func (a *API) ListDevContainers(ctx context.Context, repoID int64, branch string defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return nil, api.HandleHTTPError(resp) + return nil, githubrest.NewErrorResponse(resp) } var response struct { @@ -1169,7 +1210,7 @@ func (a *API) EditCodespace(ctx context.Context, codespaceName string, params *E if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodPatch, u.String(), bytes.NewBuffer(requestBody)) + req, err := a.newRequest(ctx, http.MethodPatch, u.String(), bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -1196,7 +1237,7 @@ func (a *API) EditCodespace(ctx context.Context, codespaceName string, params *E ) } } - return nil, api.HandleHTTPError(resp) + return nil, githubrest.NewErrorResponse(resp) } b, err := io.ReadAll(resp.Body) @@ -1233,7 +1274,7 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod if err != nil { return nil, err } - req, err := http.NewRequest(http.MethodGet, u.String(), nil) + req, err := a.newRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -1252,7 +1293,7 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod if resp.StatusCode == http.StatusNotFound { return nil, nil } else if resp.StatusCode != http.StatusOK { - return nil, api.HandleHTTPError(resp) + return nil, githubrest.NewErrorResponse(resp) } b, err := io.ReadAll(resp.Body) @@ -1281,12 +1322,22 @@ func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http defer span.Finish() req = req.WithContext(ctx) - httpClient, err := a.client() + if a.restClientErr != nil { + return nil, a.restClientErr + } + + // A non-2xx is returned as a response rather than an error, because this + // package inspects status codes itself: withRetry retries 5xx, and several + // callers map particular 4xx codes to domain errors. + resp, err := a.restClient.Send(req) if err != nil { + var errResp *githubrest.ErrorResponse + if errors.As(err, &errResp) { + return resp.Response, nil + } return nil, err } - - return httpClient.Do(req) + return resp.Response, nil } // setHeaders sets the required headers for the API. diff --git a/internal/codespaces/api/api_test.go b/internal/codespaces/api/api_test.go index 9d06dcdaf94..0f5f824701f 100644 --- a/internal/codespaces/api/api_test.go +++ b/internal/codespaces/api/api_test.go @@ -9,12 +9,15 @@ import ( "net/http/httptest" "reflect" "strconv" + "strings" "testing" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" ghmock "github.com/cli/cli/v2/internal/gh/mock" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/stretchr/testify/require" ) func generateCodespaceList(start int, end int) []*Codespace { @@ -132,8 +135,19 @@ func createFakeCreateEndpointServer(t *testing.T, wantStatus int) *httptest.Serv })) } -func createHttpClient() (*http.Client, error) { - return &http.Client{}, nil +// newTestRESTClient builds a client whose API base URL is the test server, so +// that the absolute URLs this package builds resolve and are credentialed. +func newTestRESTClient(t *testing.T, baseURL string) *githubrest.Client { + t.Helper() + client, err := githubrest.NewClient(strings.TrimSuffix(baseURL, "/")+"/", &http.Client{}, githubrest.WithToken("test-token")) + require.NoError(t, err) + return client +} + +// stubRESTClient stands in for the factory's client vendor; these tests only +// exercise endpoint resolution, not requests. +func stubRESTClient(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { + return githubrest.NewClient("https://api.github.com/", &http.Client{}, githubrest.WithToken("test-token"), opts...) } func TestNew_APIURL_dotcomConfig(t *testing.T) { @@ -145,6 +159,7 @@ func TestNew_APIURL_dotcomConfig(t *testing.T) { }, } f := &cmdutil.Factory{ + GitHubREST: stubRESTClient, Config: func() (gh.Config, error) { return cfg, nil }, @@ -170,6 +185,7 @@ func TestNew_APIURL_customConfig(t *testing.T) { }, } f := &cmdutil.Factory{ + GitHubREST: stubRESTClient, Config: func() (gh.Config, error) { return cfg, nil }, @@ -193,6 +209,7 @@ func TestNew_APIURL_env(t *testing.T) { }, } f := &cmdutil.Factory{ + GitHubREST: stubRESTClient, Config: func() (gh.Config, error) { return cfg, nil }, @@ -202,14 +219,14 @@ func TestNew_APIURL_env(t *testing.T) { if api.githubAPI != "https://api.mycompany.com" { t.Fatalf("expected https://api.mycompany.com, got %s", api.githubAPI) } - if len(cfg.AuthenticationCalls()) != 0 { - t.Fatalf("Configuration was checked instead of using the GITHUB_API_URL environment variable") - } + // The config is still read, because the REST client needs the default host + // to resolve a token, but it must not decide the endpoint. } func TestNew_APIURL_dotcomFallback(t *testing.T) { t.Setenv("GITHUB_API_URL", "") f := &cmdutil.Factory{ + GitHubREST: stubRESTClient, Config: func() (gh.Config, error) { return nil, errors.New("Failed to load") }, @@ -230,6 +247,7 @@ func TestNew_ServerURL_dotcomConfig(t *testing.T) { }, } f := &cmdutil.Factory{ + GitHubREST: stubRESTClient, Config: func() (gh.Config, error) { return cfg, nil }, @@ -255,6 +273,7 @@ func TestNew_ServerURL_customConfig(t *testing.T) { }, } f := &cmdutil.Factory{ + GitHubREST: stubRESTClient, Config: func() (gh.Config, error) { return cfg, nil }, @@ -278,6 +297,7 @@ func TestNew_ServerURL_env(t *testing.T) { }, } f := &cmdutil.Factory{ + GitHubREST: stubRESTClient, Config: func() (gh.Config, error) { return cfg, nil }, @@ -287,14 +307,14 @@ func TestNew_ServerURL_env(t *testing.T) { if api.githubServer != "https://mycompany.com" { t.Fatalf("expected https://mycompany.com, got %s", api.githubServer) } - if len(cfg.AuthenticationCalls()) != 0 { - t.Fatalf("Configuration was checked instead of using the GITHUB_SERVER_URL environment variable") - } + // The config is still read, because the REST client needs the default host + // to resolve a token, but it must not decide the endpoint. } func TestNew_ServerURL_dotcomFallback(t *testing.T) { t.Setenv("GITHUB_SERVER_URL", "") f := &cmdutil.Factory{ + GitHubREST: stubRESTClient, Config: func() (gh.Config, error) { return nil, errors.New("Failed to load") }, @@ -311,8 +331,8 @@ func TestCreateCodespaces(t *testing.T) { defer svr.Close() api := API{ - githubAPI: svr.URL, - client: createHttpClient, + githubAPI: svr.URL, + restClient: newTestRESTClient(t, svr.URL), } ctx := context.TODO() @@ -340,8 +360,8 @@ func TestCreateCodespaces_displayName(t *testing.T) { defer svr.Close() api := API{ - githubAPI: svr.URL, - client: createHttpClient, + githubAPI: svr.URL, + restClient: newTestRESTClient(t, svr.URL), } retentionPeriod := 0 @@ -366,7 +386,7 @@ func TestCreateCodespaces_Pending(t *testing.T) { api := API{ githubAPI: svr.URL, - client: createHttpClient, + restClient: newTestRESTClient(t, svr.URL), retryBackoff: 0, } @@ -392,8 +412,8 @@ func TestListCodespaces_limited(t *testing.T) { defer svr.Close() api := API{ - githubAPI: svr.URL, - client: createHttpClient, + githubAPI: svr.URL, + restClient: newTestRESTClient(t, svr.URL), } ctx := context.TODO() codespaces, err := api.ListCodespaces(ctx, ListCodespacesOptions{Limit: 200}) @@ -417,8 +437,8 @@ func TestListCodespaces_unlimited(t *testing.T) { defer svr.Close() api := API{ - githubAPI: svr.URL, - client: createHttpClient, + githubAPI: svr.URL, + restClient: newTestRESTClient(t, svr.URL), } ctx := context.TODO() codespaces, err := api.ListCodespaces(ctx, ListCodespacesOptions{}) @@ -508,8 +528,8 @@ func runRepoSearchTest(t *testing.T, searchText, wantQueryText, wantSort, wantMa defer svr.Close() api := API{ - githubAPI: svr.URL, - client: createHttpClient, + githubAPI: svr.URL, + restClient: newTestRESTClient(t, svr.URL), } ctx := context.Background() @@ -553,8 +573,8 @@ func TestRetries(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler(w, r) })) t.Cleanup(srv.Close) a := &API{ - githubAPI: srv.URL, - client: createHttpClient, + githubAPI: srv.URL, + restClient: newTestRESTClient(t, srv.URL), } cs, err := a.GetCodespace(context.Background(), "test", false) if err != nil { @@ -742,8 +762,8 @@ func TestAPI_EditCodespace(t *testing.T) { defer svr.Close() a := &API{ - client: createHttpClient, - githubAPI: svr.URL, + restClient: newTestRESTClient(t, svr.URL), + githubAPI: svr.URL, } got, err := a.EditCodespace(tt.args.ctx, tt.args.codespaceName, tt.args.params) if (err != nil) != tt.wantErr { @@ -782,8 +802,8 @@ func TestAPI_EditCodespacePendingOperation(t *testing.T) { defer svr.Close() a := &API{ - client: createHttpClient, - githubAPI: svr.URL, + restClient: newTestRESTClient(t, svr.URL), + githubAPI: svr.URL, } _, err := a.EditCodespace(context.Background(), "disabledCodespace", &EditCodespaceParams{DisplayName: "some silly name"}) From 280de9c69a7f6e07f214d332eb6068d2ed977264 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 15:10:33 +0200 Subject: [PATCH 15/21] Route feature detection through githubrest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- internal/codespaces/api/api.go | 43 ++++++++++++------- .../featuredetection/feature_detection.go | 28 ++++++++---- .../feature_detection_test.go | 16 +++++-- pkg/cmd/agent-task/shared/log_mock.go | 3 +- pkg/cmd/discussion/client/client_mock.go | 3 +- pkg/cmd/extension/command.go | 4 +- pkg/cmd/issue/create/create.go | 15 ++++++- pkg/cmd/issue/create/create_test.go | 6 +++ pkg/cmd/issue/edit/edit.go | 9 +++- pkg/cmd/issue/list/list.go | 9 +++- pkg/cmd/issue/list/list_test.go | 2 + pkg/cmd/issue/view/view.go | 9 +++- pkg/cmd/issue/view/view_test.go | 9 ++++ pkg/cmd/pr/checks/checks.go | 9 +++- pkg/cmd/pr/create/create.go | 15 ++++++- pkg/cmd/pr/create/create_test.go | 10 +++++ pkg/cmd/pr/edit/edit.go | 9 +++- pkg/cmd/pr/list/list.go | 9 +++- pkg/cmd/pr/list/list_test.go | 1 + pkg/cmd/pr/shared/finder.go | 29 +++++++++++-- pkg/cmd/pr/shared/templates.go | 5 ++- pkg/cmd/pr/status/status.go | 9 +++- pkg/cmd/project/item-list/item_list.go | 7 ++- pkg/cmd/release/list/list.go | 9 +++- pkg/cmd/repo/edit/edit.go | 9 +++- pkg/cmd/repo/edit/edit_test.go | 2 + pkg/cmd/repo/list/list.go | 9 +++- pkg/cmd/repo/list/list_test.go | 5 +++ pkg/cmd/search/shared/shared.go | 4 +- pkg/cmd/workflow/run/run.go | 9 +++- pkg/cmd/workflow/run/run_test.go | 1 + pkg/httpmock/githubrest.go | 14 +++--- 32 files changed, 259 insertions(+), 62 deletions(-) diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index 9e0c4590621..bf6ce4caf28 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -37,6 +37,7 @@ import ( "regexp" "strconv" "strings" + "sync" "time" "github.com/cenkalti/backoff/v4" @@ -61,8 +62,11 @@ const ( // API is the interface to the codespace service. type API struct { + // restClient is resolved lazily: New runs while the command tree is being + // built, on every gh invocation, so a client is only worth constructing + // once a codespaces request is actually made. Tests set it directly. restClient *githubrest.Client - restClientErr error + resolveClient func() (*githubrest.Client, error) externalClient func() (*http.Client, error) githubAPI string githubServer string @@ -99,16 +103,15 @@ func New(f *cmdutil.Factory) *API { } } - // The client is resolved here rather than per request, but New has no error - // to return, so a failure is carried and surfaced by newRequest. - restClient, restClientErr := newRESTClient(f, host, apiURL) - if cfgErr != nil { - restClient, restClientErr = nil, cfgErr - } + resolveClient := sync.OnceValues(func() (*githubrest.Client, error) { + if cfgErr != nil { + return nil, cfgErr + } + return newRESTClient(f, host, apiURL) + }) return &API{ - restClient: restClient, - restClientErr: restClientErr, + resolveClient: resolveClient, externalClient: f.ExternalHttpClient, githubAPI: strings.TrimSuffix(apiURL, "/"), githubServer: strings.TrimSuffix(serverURL, "/"), @@ -130,16 +133,25 @@ func newRESTClient(f *cmdutil.Factory, host, apiURL string) (*githubrest.Client, return f.GitHubREST(host, opts...) } +// client returns the REST client, resolving it on first use. +func (a *API) client() (*githubrest.Client, error) { + if a.restClient != nil { + return a.restClient, nil + } + return a.resolveClient() +} + // newRequest builds a request through the package's REST client. // // It exists because New cannot report a client construction failure, and // because every request in this file is built from an absolute URL derived // from a.githubAPI rather than from a relative path. func (a *API) newRequest(ctx context.Context, method, url string, body io.Reader) (*http.Request, error) { - if a.restClientErr != nil { - return nil, a.restClientErr + client, err := a.client() + if err != nil { + return nil, err } - return a.restClient.NewRequest(ctx, method, url, body) + return client.NewRequest(ctx, method, url, body) } // User represents a GitHub user. @@ -1322,14 +1334,15 @@ func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http defer span.Finish() req = req.WithContext(ctx) - if a.restClientErr != nil { - return nil, a.restClientErr + client, err := a.client() + if err != nil { + return nil, err } // A non-2xx is returned as a response rather than an error, because this // package inspects status codes itself: withRetry retries 5xx, and several // callers map particular 4xx codes to domain errors. - resp, err := a.restClient.Send(req) + resp, err := client.Send(req) if err != nil { var errResp *githubrest.ErrorResponse if errors.As(err, &errResp) { diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index e5bd8034faf..251bb6fecb3 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -1,10 +1,12 @@ package featuredetection import ( + "context" "net/http" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/hashicorp/go-version" "golang.org/x/sync/errgroup" @@ -152,13 +154,18 @@ type ActionsFeatures struct { } type detector struct { - host string + host string + // httpClient serves the GraphQL probes, restClient the REST ones. Both are + // required: which one a given feature check needs is not visible to the + // caller constructing the detector. httpClient *http.Client + restClient *githubrest.Client } -func NewDetector(httpClient *http.Client, host string) Detector { +func NewDetector(httpClient *http.Client, restClient *githubrest.Client, host string) Detector { return &detector{ httpClient: httpClient, + restClient: restClient, host: host, } } @@ -314,7 +321,7 @@ func (d *detector) ProjectsV1() gh.ProjectsV1Support { return gh.ProjectsV1Unsupported } - hostVersion, hostVersionErr := resolveEnterpriseVersion(d.httpClient, d.host) + hostVersion, hostVersionErr := resolveEnterpriseVersion(d.restClient) v1ProjectCutoffVersion, v1ProjectCutoffVersionErr := version.NewVersion(enterpriseProjectsV1Removed) if hostVersionErr == nil && v1ProjectCutoffVersionErr == nil && hostVersion.LessThan(v1ProjectCutoffVersion) { @@ -403,7 +410,7 @@ func (d *detector) SearchFeatures() (SearchFeatures, error) { return SearchFeatures{}, err } - hostVersion, err := resolveEnterpriseVersion(d.httpClient, d.host) + hostVersion, err := resolveEnterpriseVersion(d.restClient) if err != nil { return SearchFeatures{}, err } @@ -520,7 +527,7 @@ func (d *detector) ActionsFeatures() (ActionsFeatures, error) { return ActionsFeatures{}, err } - hostVersion, err := resolveEnterpriseVersion(d.httpClient, d.host) + hostVersion, err := resolveEnterpriseVersion(d.restClient) if err != nil { return ActionsFeatures{}, err } @@ -536,20 +543,25 @@ func (d *detector) ActionsFeatures() (ActionsFeatures, error) { }, nil } -func resolveEnterpriseVersion(httpClient *http.Client, host string) (*version.Version, error) { +func resolveEnterpriseVersion(client *githubrest.Client) (*version.Version, error) { var metaResponse struct { InstalledVersion string `json:"installed_version"` } - apiClient := api.NewClientFromHTTP(httpClient) u, err := safeurl.JoinPath("meta") if err != nil { return nil, err } - err = apiClient.REST(host, "GET", u.String(), nil, &metaResponse) + // The Detector interface's methods take no context, and threading one + // through every construction site is out of proportion to a single /meta + // probe. Revisit if Detector ever grows a context-aware surface. + req, err := client.NewRequest(context.Background(), http.MethodGet, u.String(), nil) if err != nil { return nil, err } + if _, err := client.Do(req, &metaResponse); err != nil { + return nil, err + } return version.NewVersion(metaResponse.InstalledVersion) } diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index 6b6ed675180..222528bdd8d 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -435,7 +435,9 @@ func TestProjectV1Support(t *testing.T) { httpClient := &http.Client{} httpmock.ReplaceTripper(httpClient, reg) - detector := NewDetector(httpClient, tt.hostname) + restClient, err := httpmock.RESTClientFunc(reg)(tt.hostname) + require.NoError(t, err) + detector := NewDetector(httpClient, restClient, tt.hostname) require.Equal(t, tt.wantFeatures, detector.ProjectsV1()) }) } @@ -578,7 +580,9 @@ func TestAdvancedIssueSearchSupport(t *testing.T) { httpClient := &http.Client{} httpmock.ReplaceTripper(httpClient, reg) - detector := NewDetector(httpClient, tt.hostname) + restClient, err := httpmock.RESTClientFunc(reg)(tt.hostname) + require.NoError(t, err) + detector := NewDetector(httpClient, restClient, tt.hostname) features, err := detector.SearchFeatures() require.NoError(t, err) @@ -775,7 +779,9 @@ func TestReleaseFeatures(t *testing.T) { httpClient := &http.Client{} httpmock.ReplaceTripper(httpClient, reg) - detector := NewDetector(httpClient, tt.hostname) + restClient, err := httpmock.RESTClientFunc(reg)(tt.hostname) + require.NoError(t, err) + detector := NewDetector(httpClient, restClient, tt.hostname) features, err := detector.ReleaseFeatures() require.NoError(t, err) @@ -843,7 +849,9 @@ func TestActionsFeatures(t *testing.T) { httpClient := &http.Client{} httpmock.ReplaceTripper(httpClient, reg) - detector := NewDetector(httpClient, tt.hostname) + restClient, err := httpmock.RESTClientFunc(reg)(tt.hostname) + require.NoError(t, err) + detector := NewDetector(httpClient, restClient, tt.hostname) features, err := detector.ActionsFeatures() require.NoError(t, err) diff --git a/pkg/cmd/agent-task/shared/log_mock.go b/pkg/cmd/agent-task/shared/log_mock.go index f12cf6bf393..180b97214ac 100644 --- a/pkg/cmd/agent-task/shared/log_mock.go +++ b/pkg/cmd/agent-task/shared/log_mock.go @@ -4,9 +4,10 @@ package shared import ( - "github.com/cli/cli/v2/pkg/iostreams" "io" "sync" + + "github.com/cli/cli/v2/pkg/iostreams" ) // Ensure, that LogRendererMock does implement LogRenderer. diff --git a/pkg/cmd/discussion/client/client_mock.go b/pkg/cmd/discussion/client/client_mock.go index 77e12b10cad..e7990a80238 100644 --- a/pkg/cmd/discussion/client/client_mock.go +++ b/pkg/cmd/discussion/client/client_mock.go @@ -4,8 +4,9 @@ package client import ( - "github.com/cli/cli/v2/internal/ghrepo" "sync" + + "github.com/cli/cli/v2/internal/ghrepo" ) // Ensure, that DiscussionClientMock does implement DiscussionClient. diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index a82c10150bb..458d66570ad 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -169,11 +169,11 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { query.Qualifiers = qualifiers host, _ := cfg.Authentication().DefaultHost() - detector := featuredetection.NewDetector(client, host) restClient, err := f.GitHubREST(host) if err != nil { return err } + detector := featuredetection.NewDetector(client, restClient, host) searcher := search.NewSearcher(cmd.Context(), restClient, host, detector) if webMode { @@ -519,11 +519,11 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { return err } - detector := featuredetection.NewDetector(client, host) restClient, err := f.GitHubREST(host, githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) if err != nil { return err } + detector := featuredetection.NewDetector(client, restClient, host) searcher := search.NewSearcher(cmd.Context(), restClient, host, detector) gc.Stderr = gio.Discard diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 23f332d7048..fe7db3671b8 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -12,6 +12,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/text" issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" @@ -24,6 +25,7 @@ import ( type CreateOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Config func() (gh.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) @@ -60,6 +62,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, Browser: f.Browser, Prompter: f.Prompter, @@ -176,7 +179,11 @@ func createRun(opts *CreateOptions) (err error) { // Remove this section as we should no longer need to detect if opts.Detector == nil { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + restClient, err := opts.GitHubREST(baseRepo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + opts.Detector = fd.NewDetector(cachedClient, restClient, baseRepo.RepoHost()) } projectsV1Support := opts.Detector.ProjectsV1() @@ -221,7 +228,11 @@ func createRun(opts *CreateOptions) (err error) { } } - tpl := prShared.NewTemplateManager(httpClient, baseRepo, opts.Prompter, opts.RootDirOverride, !opts.HasRepoOverride, false) + tplRESTClient, err := opts.GitHubREST(baseRepo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + tpl := prShared.NewTemplateManager(httpClient, tplRESTClient, baseRepo, opts.Prompter, opts.RootDirOverride, !opts.HasRepoOverride, false) if opts.WebMode { var openURL string diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index bd39dfd9bdb..3b75366b4ed 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -943,6 +943,7 @@ func Test_createRun(t *testing.T) { opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: httpReg}, nil } + opts.GitHubREST = httpmock.RESTClientFunc(httpReg) opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } @@ -988,6 +989,7 @@ func runCommandWithRootDirOverridden(rt http.RoundTripper, isTTY bool, cli strin HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, + GitHubREST: httpmock.RESTClientFunc(rt), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, @@ -1587,6 +1589,7 @@ func TestProjectsV1Deprecation(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -1619,6 +1622,7 @@ func TestProjectsV1Deprecation(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -1657,6 +1661,7 @@ func TestProjectsV1Deprecation(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -1687,6 +1692,7 @@ func TestProjectsV1Deprecation(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 5cf52d92e60..40c3451503d 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -13,6 +13,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/text" issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -23,6 +24,7 @@ import ( type EditOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Prompter prShared.EditPrompter @@ -54,6 +56,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman opts := &EditOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, DetermineEditor: func() (string, error) { return cmdutil.DetermineEditor(f.Config) }, FieldsToEditSurvey: prShared.FieldsToEditSurvey, EditFieldsSurvey: prShared.EditFieldsSurvey, @@ -270,7 +273,11 @@ func editRun(opts *EditOptions) error { if opts.Detector == nil { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + restClient, err := opts.GitHubREST(baseRepo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + opts.Detector = fd.NewDetector(cachedClient, restClient, baseRepo.RepoHost()) } issueFeatures, err := opts.Detector.IssueFeatures() diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 1fe607193cd..6df4e881ff1 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -13,6 +13,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/text" issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -24,6 +25,7 @@ import ( type ListOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Config func() (gh.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) @@ -49,6 +51,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman opts := &ListOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, Browser: f.Browser, Now: time.Now, @@ -149,7 +152,11 @@ func listRun(opts *ListOptions) error { if opts.Detector == nil { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + restClient, err := opts.GitHubREST(baseRepo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + opts.Detector = fd.NewDetector(cachedClient, restClient, baseRepo.RepoHost()) } fields := append(defaultFields, "stateReason") diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index af0879a6b5e..dd12b26a9a2 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -84,6 +84,7 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, + GitHubREST: httpmock.RESTClientFunc(rt), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, @@ -278,6 +279,7 @@ func TestIssueList_web(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 7d562f864b0..9d7da98a13b 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -14,6 +14,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/text" issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared" prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -26,6 +27,7 @@ import ( type ViewOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser @@ -43,6 +45,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman opts := &ViewOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Browser: f.Browser, Now: time.Now, } @@ -121,7 +124,11 @@ func viewRun(opts *ViewOptions) error { // Remove this section as we should no longer add projectCards if opts.Detector == nil { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + restClient, err := opts.GitHubREST(baseRepo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + opts.Detector = fd.NewDetector(cachedClient, restClient, baseRepo.RepoHost()) } lookupFields.Add("projectItems") diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index d11afe8c0b4..3d0dee61215 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -73,6 +73,7 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, + GitHubREST: httpmock.RESTClientFunc(rt), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, @@ -127,6 +128,7 @@ func TestIssueView_web(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -311,6 +313,7 @@ func TestIssueView_tty_Preview(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: httpReg}, nil }, + GitHubREST: httpmock.RESTClientFunc(httpReg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -557,6 +560,7 @@ func TestProjectsV1Deprecation(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -584,6 +588,7 @@ func TestProjectsV1Deprecation(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -730,6 +735,7 @@ func TestIssueView_tty_Issues2AllFields(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: httpReg}, nil }, + GitHubREST: httpmock.RESTClientFunc(httpReg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -802,6 +808,7 @@ func TestIssueView_nontty_Issues2AllFields(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: httpReg}, nil }, + GitHubREST: httpmock.RESTClientFunc(httpReg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -847,6 +854,7 @@ func TestIssueView_tty_Issues2NoFields(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: httpReg}, nil }, + GitHubREST: httpmock.RESTClientFunc(httpReg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -896,6 +904,7 @@ func TestIssueView_nontty_Issues2NoFields(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: httpReg}, nil }, + GitHubREST: httpmock.RESTClientFunc(httpReg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go index 9256958aa1f..70af82414bf 100644 --- a/pkg/cmd/pr/checks/checks.go +++ b/pkg/cmd/pr/checks/checks.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/internal/browser" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -34,6 +35,7 @@ var prCheckFields = []string{ type ChecksOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Browser browser.Browser Exporter cmdutil.Exporter @@ -53,6 +55,7 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co var interval int opts := &ChecksOptions{ HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, IO: f.IOStreams, Browser: f.Browser, Interval: defaultInterval, @@ -173,7 +176,11 @@ func checksRun(opts *ChecksOptions) error { if opts.Detector == nil { cachedClient := api.NewCachedHTTPClient(client, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, repo.RepoHost()) + restClient, err := opts.GitHubREST(repo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + opts.Detector = fd.NewDetector(cachedClient, restClient, repo.RepoHost()) } if features, featuresErr := opts.Detector.PullRequestFeatures(); featuresErr != nil { return featuresErr diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 1a3eba7d882..e2744124535 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -21,6 +21,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -35,6 +36,7 @@ type CreateOptions struct { // This struct stores user input and factory functions Detector fd.Detector HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) GitClient *git.Client Config func() (gh.Config, error) IO *iostreams.IOStreams @@ -195,6 +197,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, GitClient: f.GitClient, Config: f.Config, Remotes: f.Remotes, @@ -392,7 +395,11 @@ func createRun(opts *CreateOptions) error { // Remove this section as we should no longer need to detect if opts.Detector == nil { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, ctx.PRRefs.BaseRepo().RepoHost()) + restClient, err := opts.GitHubREST(ctx.PRRefs.BaseRepo().RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + opts.Detector = fd.NewDetector(cachedClient, restClient, ctx.PRRefs.BaseRepo().RepoHost()) } projectsV1Support := opts.Detector.ProjectsV1() @@ -519,7 +526,11 @@ func createRun(opts *CreateOptions) error { action = shared.SubmitDraftAction } - tpl := shared.NewTemplateManager(client.HTTP(), ctx.PRRefs.BaseRepo(), opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true) + restClient, err := opts.GitHubREST(ctx.PRRefs.BaseRepo().RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + tpl := shared.NewTemplateManager(client.HTTP(), restClient, ctx.PRRefs.BaseRepo(), opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true) if opts.EditorMode { if opts.Template != "" { diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index a622b60c891..4bc9d7cb7c4 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -1602,6 +1602,7 @@ func Test_createRun(t *testing.T) { opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + opts.GitHubREST = httpmock.RESTClientFunc(reg) opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -2130,6 +2131,7 @@ func Test_createRun_GHES(t *testing.T) { opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + opts.GitHubREST = httpmock.RESTClientFunc(reg) opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -2218,6 +2220,7 @@ func TestRemoteGuessing(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, @@ -2293,6 +2296,7 @@ func TestNoRepoCanBeDetermined(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, @@ -2658,6 +2662,7 @@ func TestProjectsV1Deprecation(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", @@ -2710,6 +2715,7 @@ func TestProjectsV1Deprecation(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", @@ -2815,6 +2821,7 @@ func TestProjectsV1Deprecation(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, @@ -2910,6 +2917,7 @@ func TestProjectsV1Deprecation(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, @@ -2974,6 +2982,7 @@ func TestProjectsV1Deprecation(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", @@ -3028,6 +3037,7 @@ func TestProjectsV1Deprecation(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{ GhPath: "some/path/gh", GitPath: "some/path/git", diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index af5e04631f1..59d5e3d1364 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -12,6 +12,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" shared "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -23,6 +24,7 @@ import ( type EditOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Finder shared.PRFinder @@ -43,6 +45,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman opts := &EditOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Surveyor: surveyor{P: f.Prompter}, Fetcher: fetcher{}, EditorRetriever: editorRetriever{config: f.Config}, @@ -256,7 +259,11 @@ func editRun(opts *EditOptions) error { } cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + restClient, err := opts.GitHubREST(baseRepo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + opts.Detector = fd.NewDetector(cachedClient, restClient, baseRepo.RepoHost()) } findOptions := shared.FindOptions{ diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 48c4649b626..229cd9a191b 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/internal/browser" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/pr/shared" @@ -22,6 +23,7 @@ import ( type ListOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser @@ -47,6 +49,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman opts := &ListOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Browser: f.Browser, Now: time.Now, } @@ -155,7 +158,11 @@ func listRun(opts *ListOptions) error { if opts.Detector == nil { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + restClient, err := opts.GitHubREST(baseRepo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + opts.Detector = fd.NewDetector(cachedClient, restClient, baseRepo.RepoHost()) } prState := strings.ToLower(opts.State) diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go index 92b5834c0a3..af115fffb56 100644 --- a/pkg/cmd/pr/list/list_test.go +++ b/pkg/cmd/pr/list/list_test.go @@ -37,6 +37,7 @@ func runCommand(rt http.RoundTripper, detector fd.Detector, isTTY bool, cli stri HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, + GitHubREST: httpmock.RESTClientFunc(rt), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index ade90653418..c2f27ad1b6c 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -19,6 +19,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmdutil" o "github.com/cli/cli/v2/pkg/option" "github.com/cli/cli/v2/pkg/set" @@ -46,6 +47,7 @@ type finder struct { baseRepoFn func() (ghrepo.Interface, error) branchFn func() (string, error) httpClient func() (*http.Client, error) + gitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) remotesFn func() (ghContext.Remotes, error) gitConfigClient GitConfigClient progress progressIndicator @@ -66,12 +68,25 @@ func NewFinder(factory *cmdutil.Factory) PRFinder { baseRepoFn: factory.BaseRepo, branchFn: factory.Branch, httpClient: factory.HttpClient, + gitHubREST: factory.GitHubREST, gitConfigClient: factory.GitClient, remotesFn: factory.Remotes, progress: factory.IOStreams, } } +// newDetector builds a feature detector against the base repo's host, with +// both clients caching for a day: feature support does not change often, and +// several fields can each trigger a probe within one command. +func (f *finder) newDetector(httpClient *http.Client) (fd.Detector, error) { + host := f.baseRefRepo.RepoHost() + restClient, err := f.gitHubREST(host, githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return nil, err + } + return fd.NewDetector(api.NewCachedHTTPClient(httpClient, time.Hour*24), restClient, host), nil +} + var finderForRunCommandStyleTests PRFinder // StubFinderForRunCommandStyleTests is the NewMockFinder substitute to be used ONLY in runCommand-style tests. @@ -211,8 +226,11 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err if fields.Contains("isInMergeQueue") || fields.Contains("isMergeQueueEnabled") { if opts.Detector == nil { - cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, f.baseRefRepo.RepoHost()) + detector, err := f.newDetector(httpClient) + if err != nil { + return nil, nil, err + } + opts.Detector = detector } prFeatures, err := opts.Detector.PullRequestFeatures() if err != nil { @@ -236,8 +254,11 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err // When removing this, remember to remove `projectCards` from the list of default fields in pr/view.go if fields.Contains("projectCards") { if opts.Detector == nil { - cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, f.baseRefRepo.RepoHost()) + detector, err := f.newDetector(httpClient) + if err != nil { + return nil, nil, err + } + opts.Detector = detector } if opts.Detector.ProjectsV1() == gh.ProjectsV1Unsupported { diff --git a/pkg/cmd/pr/shared/templates.go b/pkg/cmd/pr/shared/templates.go index b8c9ea71926..88b49e897a7 100644 --- a/pkg/cmd/pr/shared/templates.go +++ b/pkg/cmd/pr/shared/templates.go @@ -11,6 +11,7 @@ import ( "github.com/cli/cli/v2/git" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/githubtemplate" "github.com/shurcooL/githubv4" ) @@ -141,7 +142,7 @@ type templateManager struct { fetchError error } -func NewTemplateManager(httpClient *http.Client, repo ghrepo.Interface, p iprompter, dir string, allowFS bool, isPR bool) *templateManager { +func NewTemplateManager(httpClient *http.Client, restClient *githubrest.Client, repo ghrepo.Interface, p iprompter, dir string, allowFS bool, isPR bool) *templateManager { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) return &templateManager{ repo: repo, @@ -150,7 +151,7 @@ func NewTemplateManager(httpClient *http.Client, repo ghrepo.Interface, p ipromp isPR: isPR, httpClient: httpClient, prompter: p, - detector: fd.NewDetector(cachedClient, repo.RepoHost()), + detector: fd.NewDetector(cachedClient, restClient, repo.RepoHost()), } } diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 60202594f54..61398554199 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -16,6 +16,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -25,6 +26,7 @@ import ( type StatusOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) GitClient *git.Client Config func() (gh.Config, error) IO *iostreams.IOStreams @@ -43,6 +45,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co opts := &StatusOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, GitClient: f.GitClient, Config: f.Config, Remotes: f.Remotes, @@ -145,7 +148,11 @@ func statusRun(opts *StatusOptions) error { if opts.Detector == nil { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, baseRefRepo.RepoHost()) + restClient, err := opts.GitHubREST(baseRefRepo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + opts.Detector = fd.NewDetector(cachedClient, restClient, baseRefRepo.RepoHost()) } prFeatures, err := opts.Detector.PullRequestFeatures() if err != nil { diff --git a/pkg/cmd/project/item-list/item_list.go b/pkg/cmd/project/item-list/item_list.go index f924195d704..cebc730710f 100644 --- a/pkg/cmd/project/item-list/item_list.go +++ b/pkg/cmd/project/item-list/item_list.go @@ -8,6 +8,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" fd "github.com/cli/cli/v2/internal/featuredetection" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/project/shared/client" "github.com/cli/cli/v2/pkg/cmd/project/shared/queries" @@ -113,7 +114,11 @@ func NewCmdList(f *cmdutil.Factory, runF func(config listConfig) error) *cobra.C return err } host, _ := cfg.Authentication().DefaultHost() - config.detector = fd.NewDetector(api.NewCachedHTTPClient(httpClient, time.Hour*24), host) + restClient, err := f.GitHubREST(host, githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + config.detector = fd.NewDetector(api.NewCachedHTTPClient(httpClient, time.Hour*24), restClient, host) } return runList(config) diff --git a/pkg/cmd/release/list/list.go b/pkg/cmd/release/list/list.go index 28e8c93a0db..a9b6d229711 100644 --- a/pkg/cmd/release/list/list.go +++ b/pkg/cmd/release/list/list.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/api" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" @@ -17,6 +18,7 @@ import ( type ListOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) @@ -33,6 +35,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman opts := &ListOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -80,7 +83,11 @@ func listRun(opts *ListOptions) error { // immutable releases (probably when 3.18 goes EOL). if opts.Detector == nil { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost()) + restClient, err := opts.GitHubREST(baseRepo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + opts.Detector = fd.NewDetector(cachedClient, restClient, baseRepo.RepoHost()) } releaseFeatures, err := opts.Detector.ReleaseFeatures() diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index c215f182ccc..788b52f6ef4 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -16,6 +16,7 @@ import ( fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" @@ -60,6 +61,7 @@ const ( type EditOptions struct { HTTPClient *http.Client + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Repository ghrepo.Interface IO *iostreams.IOStreams Edits EditRepositoryInput @@ -169,6 +171,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobr } else { return err } + opts.GitHubREST = f.GitHubREST if cmd.Flags().NFlag() == 0 { opts.InteractiveMode = true @@ -240,7 +243,11 @@ func editRun(ctx context.Context, opts *EditOptions) error { detector := opts.Detector if detector == nil { cachedClient := api.NewCachedHTTPClient(opts.HTTPClient, time.Hour*24) - detector = fd.NewDetector(cachedClient, repo.RepoHost()) + restClient, err := opts.GitHubREST(repo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + detector = fd.NewDetector(cachedClient, restClient, repo.RepoHost()) } repoFeatures, err := detector.RepositoryFeatures() if err != nil { diff --git a/pkg/cmd/repo/edit/edit_test.go b/pkg/cmd/repo/edit/edit_test.go index d4b297a1902..173c70dcdba 100644 --- a/pkg/cmd/repo/edit/edit_test.go +++ b/pkg/cmd/repo/edit/edit_test.go @@ -325,6 +325,7 @@ func Test_editRun(t *testing.T) { opts := &tt.opts opts.HTTPClient = &http.Client{Transport: httpReg} + opts.GitHubREST = httpmock.RESTClientFunc(httpReg) opts.IO = ios err := editRun(context.Background(), opts) if tt.wantsErr == "" { @@ -828,6 +829,7 @@ func Test_editRun_interactive(t *testing.T) { opts := &tt.opts opts.HTTPClient = &http.Client{Transport: httpReg} + opts.GitHubREST = httpmock.RESTClientFunc(httpReg) opts.IO = ios err := editRun(context.Background(), opts) diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index 07eff1d743e..6556d1ff50e 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/api" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" @@ -20,6 +21,7 @@ import ( type ListOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Config func() (gh.Config, error) IO *iostreams.IOStreams Exporter cmdutil.Exporter @@ -44,6 +46,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Now: time.Now, } @@ -131,7 +134,11 @@ func listRun(opts *ListOptions) error { if opts.Detector == nil { cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, host) + restClient, err := opts.GitHubREST(host, githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + opts.Detector = fd.NewDetector(cachedClient, restClient, host) } features, err := opts.Detector.RepositoryFeatures() if err != nil { diff --git a/pkg/cmd/repo/list/list_test.go b/pkg/cmd/repo/list/list_test.go index 93b5a7ed720..5e9b7c425b7 100644 --- a/pkg/cmd/repo/list/list_test.go +++ b/pkg/cmd/repo/list/list_test.go @@ -356,6 +356,7 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, + GitHubREST: httpmock.RESTClientFunc(rt), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, @@ -399,6 +400,7 @@ func TestRepoList_nontty(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: httpReg}, nil }, + GitHubREST: httpmock.RESTClientFunc(httpReg), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, @@ -440,6 +442,7 @@ func TestRepoList_tty(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: httpReg}, nil }, + GitHubREST: httpmock.RESTClientFunc(httpReg), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, @@ -510,6 +513,7 @@ func TestRepoList_noVisibilityField(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, @@ -548,6 +552,7 @@ func TestRepoList_invalidOwner(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, diff --git a/pkg/cmd/search/shared/shared.go b/pkg/cmd/search/shared/shared.go index 9d4c73bbd29..e69d1ae8a61 100644 --- a/pkg/cmd/search/shared/shared.go +++ b/pkg/cmd/search/shared/shared.go @@ -51,13 +51,13 @@ func Searcher(ctx context.Context, f *cmdutil.Factory) (search.Searcher, error) return nil, err } - detector := fd.NewDetector(client, host) - restClient, err := f.GitHubREST(host) if err != nil { return nil, err } + detector := fd.NewDetector(client, restClient, host) + return search.NewSearcher(ctx, restClient, host, detector), nil } diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index 350c59bcd2c..70ceecb9e41 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -17,6 +17,7 @@ import ( "github.com/cli/cli/v2/api" fd "github.com/cli/cli/v2/internal/featuredetection" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -27,6 +28,7 @@ import ( type RunOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Detector fd.Detector @@ -52,6 +54,7 @@ func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command opts := &RunOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Prompter: f.Prompter, } @@ -269,7 +272,11 @@ func runRun(opts *RunOptions) error { if opts.Detector == nil { cachedClient := api.NewCachedHTTPClient(c, time.Hour*24) - opts.Detector = fd.NewDetector(cachedClient, repo.RepoHost()) + restClient, err := opts.GitHubREST(repo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + if err != nil { + return err + } + opts.Detector = fd.NewDetector(cachedClient, restClient, repo.RepoHost()) } ref := opts.Ref diff --git a/pkg/cmd/workflow/run/run_test.go b/pkg/cmd/workflow/run/run_test.go index 44a3b27853a..4370130fc8a 100644 --- a/pkg/cmd/workflow/run/run_test.go +++ b/pkg/cmd/workflow/run/run_test.go @@ -1119,6 +1119,7 @@ jobs: tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, _ := iostreams.Test() ios.SetStdinTTY(tt.tty) diff --git a/pkg/httpmock/githubrest.go b/pkg/httpmock/githubrest.go index a6535c2d3c2..1af244a8354 100644 --- a/pkg/httpmock/githubrest.go +++ b/pkg/httpmock/githubrest.go @@ -8,7 +8,7 @@ import ( ) // RESTClientFunc returns a stand-in for cmdutil.Factory.GitHubREST that serves -// every host from reg. +// every host from rt, which is usually a *Registry. // // Command tests set Options.GitHubREST to this rather than building a client by // hand, because the construction is identical everywhere and repeating it puts @@ -18,21 +18,21 @@ import ( // The token is a fixed placeholder. A test that cares which credentials are // sent should assert on the request's Authorization header, and one that needs // a particular token should build its own client. -func RESTClientFunc(reg *Registry) func(string, ...githubrest.ClientOption) (*githubrest.Client, error) { - return restClientFunc(reg, githubrest.WithToken("test-token")) +func RESTClientFunc(rt http.RoundTripper) func(string, ...githubrest.ClientOption) (*githubrest.Client, error) { + return restClientFunc(rt, githubrest.WithToken("test-token")) } // RESTClientFuncAnonymous is RESTClientFunc for // cmdutil.Factory.GitHubRESTAnonymous, sending no token. -func RESTClientFuncAnonymous(reg *Registry) func(string, ...githubrest.ClientOption) (*githubrest.Client, error) { - return restClientFunc(reg, githubrest.WithoutToken()) +func RESTClientFuncAnonymous(rt http.RoundTripper) func(string, ...githubrest.ClientOption) (*githubrest.Client, error) { + return restClientFunc(rt, githubrest.WithoutToken()) } -func restClientFunc(reg *Registry, auth githubrest.AuthStrategy) func(string, ...githubrest.ClientOption) (*githubrest.Client, error) { +func restClientFunc(rt http.RoundTripper, auth githubrest.AuthStrategy) func(string, ...githubrest.ClientOption) (*githubrest.Client, error) { return func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { opts = append([]githubrest.ClientOption{ githubrest.WithCredentialedHost(ghinstance.UploadHost(host)), }, opts...) - return githubrest.NewClient(ghinstance.RESTPrefix(host), &http.Client{Transport: reg}, auth, opts...) + return githubrest.NewClient(ghinstance.RESTPrefix(host), &http.Client{Transport: rt}, auth, opts...) } } From a5958aa67c43072c7ae7d3f3753585a1e2e8d541 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 15:43:00 +0200 Subject: [PATCH 16/21] Route gist, label, cache, ruleset, run, workflow, secret, variable and key commands through githubrest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- pkg/cmd/auth/shared/login_flow.go | 8 ++- pkg/cmd/cache/delete/delete.go | 47 ++++++++------ pkg/cmd/cache/delete/delete_test.go | 8 +-- pkg/cmd/cache/list/list.go | 17 +++-- pkg/cmd/cache/list/list_test.go | 7 +- pkg/cmd/cache/shared/shared.go | 14 ++-- pkg/cmd/cache/shared/shared_test.go | 9 ++- pkg/cmd/gist/delete/delete.go | 25 +++++--- pkg/cmd/gist/delete/delete_test.go | 11 ++-- pkg/cmd/gist/edit/edit.go | 28 +++++--- pkg/cmd/gist/edit/edit_test.go | 4 +- pkg/cmd/gist/rename/rename.go | 26 ++++++-- pkg/cmd/gist/rename/rename_test.go | 4 +- pkg/cmd/gist/shared/shared.go | 11 ++-- pkg/cmd/gist/view/view.go | 15 ++++- pkg/cmd/gist/view/view_test.go | 9 ++- pkg/cmd/gpg-key/add/add.go | 23 +++---- pkg/cmd/gpg-key/add/add_test.go | 23 ++++--- pkg/cmd/gpg-key/add/http.go | 14 ++-- pkg/cmd/gpg-key/delete/delete.go | 22 ++++--- pkg/cmd/gpg-key/delete/delete_test.go | 15 +++-- pkg/cmd/gpg-key/delete/http.go | 25 ++++---- pkg/cmd/gpg-key/list/http.go | 12 ++-- pkg/cmd/gpg-key/list/list.go | 21 +++--- pkg/cmd/gpg-key/list/list_test.go | 27 +++++--- pkg/cmd/label/clone.go | 24 ++++--- pkg/cmd/label/clone_test.go | 4 +- pkg/cmd/label/create.go | 37 ++++++----- pkg/cmd/label/create_test.go | 8 +-- pkg/cmd/label/delete.go | 27 ++++---- pkg/cmd/label/delete_test.go | 8 +-- pkg/cmd/label/edit.go | 19 +++--- pkg/cmd/label/edit_test.go | 8 +-- pkg/cmd/ruleset/check/check.go | 20 ++++-- pkg/cmd/ruleset/check/check_test.go | 4 +- pkg/cmd/ruleset/view/http.go | 19 +++--- pkg/cmd/ruleset/view/view.go | 22 +++++-- pkg/cmd/ruleset/view/view_test.go | 4 +- pkg/cmd/run/cancel/cancel.go | 33 +++++----- pkg/cmd/run/cancel/cancel_test.go | 7 +- pkg/cmd/run/delete/delete.go | 34 +++++----- pkg/cmd/run/delete/delete_test.go | 7 +- pkg/cmd/run/list/list.go | 21 +++--- pkg/cmd/run/list/list_test.go | 7 +- pkg/cmd/run/rerun/rerun.go | 43 +++++++------ pkg/cmd/run/rerun/rerun_test.go | 7 +- pkg/cmd/run/shared/shared.go | 56 ++++++++++------ pkg/cmd/run/shared/shared_test.go | 8 +-- pkg/cmd/run/view/view.go | 12 ++-- pkg/cmd/run/watch/watch.go | 27 +++++--- pkg/cmd/run/watch/watch_test.go | 3 +- pkg/cmd/secret/delete/delete.go | 27 ++++---- pkg/cmd/secret/delete/delete_test.go | 28 +++----- pkg/cmd/secret/list/list.go | 81 +++++++++++++++--------- pkg/cmd/secret/list/list_test.go | 21 +++--- pkg/cmd/secret/set/http.go | 58 +++++++++-------- pkg/cmd/secret/set/set.go | 40 ++++++++---- pkg/cmd/secret/set/set_test.go | 20 ++++-- pkg/cmd/ssh-key/add/add.go | 25 ++++---- pkg/cmd/ssh-key/add/add_test.go | 13 ++-- pkg/cmd/ssh-key/add/http.go | 33 ++++++---- pkg/cmd/ssh-key/delete/delete.go | 22 ++++--- pkg/cmd/ssh-key/delete/delete_test.go | 15 +++-- pkg/cmd/ssh-key/delete/http.go | 25 ++++---- pkg/cmd/ssh-key/list/list.go | 21 +++--- pkg/cmd/ssh-key/list/list_test.go | 29 +++++---- pkg/cmd/ssh-key/shared/user_keys.go | 21 +++--- pkg/cmd/ssh-key/shared/user_keys_test.go | 5 +- pkg/cmd/status/status.go | 63 ++++++++++++------ pkg/cmd/status/status_test.go | 4 +- pkg/cmd/variable/delete/delete.go | 27 ++++---- pkg/cmd/variable/delete/delete_test.go | 8 +-- pkg/cmd/variable/get/get.go | 29 +++++---- pkg/cmd/variable/get/get_test.go | 8 +-- pkg/cmd/variable/list/list.go | 64 +++++++++++-------- pkg/cmd/variable/list/list_test.go | 21 +++--- pkg/cmd/variable/set/http.go | 65 +++++++++++-------- pkg/cmd/variable/set/set.go | 21 ++++-- pkg/cmd/variable/set/set_test.go | 14 ++-- pkg/cmd/variable/shared/shared.go | 12 +++- pkg/cmd/workflow/disable/disable.go | 27 ++++---- pkg/cmd/workflow/disable/disable_test.go | 7 +- pkg/cmd/workflow/enable/enable.go | 27 ++++---- pkg/cmd/workflow/enable/enable_test.go | 7 +- pkg/cmd/workflow/list/list.go | 19 +++--- pkg/cmd/workflow/list/list_test.go | 7 +- pkg/cmd/workflow/run/run.go | 25 +++++--- pkg/cmd/workflow/run/run_test.go | 2 +- pkg/cmd/workflow/shared/shared.go | 45 ++++++++----- pkg/cmd/workflow/shared/shared_test.go | 21 +++--- pkg/cmd/workflow/view/view.go | 26 +++++--- pkg/cmd/workflow/view/view_test.go | 3 +- 92 files changed, 1124 insertions(+), 806 deletions(-) diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 69cb427a853..50f2319aaa6 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -215,7 +215,7 @@ func Login(ctx context.Context, opts *LoginOptions) error { } if keyToUpload != "" { - uploaded, err := sshKeyUpload(httpClient, hostname, keyToUpload, keyTitle) + uploaded, err := sshKeyUpload(ctx, opts.RESTClient, authToken, keyToUpload, keyTitle) if err != nil { return err } @@ -243,14 +243,16 @@ func scopesSentence(scopes []string) string { return strings.Join(quoted, ", ") } -func sshKeyUpload(httpClient *http.Client, hostname, keyFile string, title string) (bool, error) { +// sshKeyUpload passes the token per request because login has not yet written +// it to config, so the client it was given carries no credentials. +func sshKeyUpload(ctx context.Context, client *githubrest.Client, authToken, keyFile, title string) (bool, error) { f, err := os.Open(keyFile) if err != nil { return false, err } defer f.Close() - return add.SSHKeyUpload(httpClient, hostname, f, title) + return add.SSHKeyUpload(ctx, client, f, title, githubrest.WithSingleRequestToken(authToken)) } // httpClient is the GraphQL leg of login, which is out of scope for the REST diff --git a/pkg/cmd/cache/delete/delete.go b/pkg/cmd/cache/delete/delete.go index 155eb2b3825..f55cb6b811b 100644 --- a/pkg/cmd/cache/delete/delete.go +++ b/pkg/cmd/cache/delete/delete.go @@ -1,13 +1,13 @@ package delete import ( + "context" "errors" "fmt" "net/http" "strconv" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" @@ -20,7 +20,7 @@ import ( type DeleteOptions struct { BaseRepo func() (ghrepo.Interface, error) - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams DeleteAll bool @@ -32,7 +32,7 @@ type DeleteOptions struct { func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { opts := &DeleteOptions{ IO: f.IOStreams, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -106,7 +106,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return runF(opts) } - return deleteRun(opts) + return deleteRun(cmd.Context(), opts) }, } @@ -117,22 +117,21 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return cmd } -func deleteRun(opts *DeleteOptions) error { - httpClient, err := opts.HttpClient() +func deleteRun(ctx context.Context, opts *DeleteOptions) error { + repo, err := opts.BaseRepo() if err != nil { - return fmt.Errorf("failed to create http client: %w", err) + return fmt.Errorf("failed to determine base repo: %w", err) } - client := api.NewClientFromHTTP(httpClient) - repo, err := opts.BaseRepo() + client, err := opts.GitHubREST(repo.RepoHost()) if err != nil { - return fmt.Errorf("failed to determine base repo: %w", err) + return fmt.Errorf("failed to create http client: %w", err) } var toDelete []string if opts.DeleteAll { opts.IO.StartProgressIndicator() - caches, err := shared.GetCaches(client, repo, shared.GetCachesOptions{Limit: -1, Ref: opts.Ref}) + caches, err := shared.GetCaches(ctx, client, repo, shared.GetCachesOptions{Limit: -1, Ref: opts.Ref}) opts.IO.StopProgressIndicator() if err != nil { return err @@ -154,10 +153,10 @@ func deleteRun(opts *DeleteOptions) error { toDelete = append(toDelete, opts.Identifier) } - return deleteCaches(opts, client, repo, toDelete) + return deleteCaches(ctx, opts, client, repo, toDelete) } -func deleteCaches(opts *DeleteOptions, client *api.Client, repo ghrepo.Interface, toDelete []string) error { +func deleteCaches(ctx context.Context, opts *DeleteOptions, client *githubrest.Client, repo ghrepo.Interface, toDelete []string) error { cs := opts.IO.ColorScheme() repoName := ghrepo.FullName(repo) opts.IO.StartProgressIndicator() @@ -167,10 +166,10 @@ func deleteCaches(opts *DeleteOptions, client *api.Client, repo ghrepo.Interface var count int var err error if id, ok := parseCacheID(cache); ok { - err = deleteCacheByID(client, repo, id) + err = deleteCacheByID(ctx, client, repo, id) count = 1 } else { - count, err = deleteCacheByKey(client, repo, cache, opts.Ref) + count, err = deleteCacheByKey(ctx, client, repo, cache, opts.Ref) } if err != nil { @@ -202,13 +201,18 @@ func deleteCaches(opts *DeleteOptions, client *api.Client, repo ghrepo.Interface return nil } -func deleteCacheByID(client *api.Client, repo ghrepo.Interface, id int64) error { +func deleteCacheByID(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, id int64) error { // returns HTTP 204 (NO CONTENT) on success path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "caches", strconv.FormatInt(id, 10)) if err != nil { return err } - return client.REST(repo.RepoHost(), "DELETE", path.String(), nil, nil) + req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), nil) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err } // deleteCacheByKey deletes cache entries by given key (and optional ref) and @@ -217,7 +221,7 @@ func deleteCacheByID(client *api.Client, repo ghrepo.Interface, id int64) error // Note that a key/ref combination does not necessarily map to a single cache // entry. There may be more than one entries with the same key/ref combination, // but those entries will have different IDs. -func deleteCacheByKey(client *api.Client, repo ghrepo.Interface, key, ref string) (int, error) { +func deleteCacheByKey(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, key, ref string) (int, error) { u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "caches") if err != nil { return 0, err @@ -226,11 +230,14 @@ func deleteCacheByKey(client *api.Client, repo ghrepo.Interface, key, ref string if ref != "" { u.SetQuery("ref", ref) } - var payload shared.CachePayload - err = client.REST(repo.RepoHost(), "DELETE", u.String(), nil, &payload) + req, err := client.NewRequest(ctx, http.MethodDelete, u.String(), nil) if err != nil { return 0, err } + var payload shared.CachePayload + if _, err := client.Do(req, &payload); err != nil { + return 0, err + } return payload.TotalCount, nil } diff --git a/pkg/cmd/cache/delete/delete_test.go b/pkg/cmd/cache/delete/delete_test.go index 51ad18e1eee..22b77d8fa5e 100644 --- a/pkg/cmd/cache/delete/delete_test.go +++ b/pkg/cmd/cache/delete/delete_test.go @@ -2,7 +2,7 @@ package delete import ( "bytes" - "net/http" + "context" "net/url" "testing" "time" @@ -458,9 +458,7 @@ func TestDeleteRun(t *testing.T) { if tt.stubs != nil { tt.stubs(reg) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.tty) ios.SetStdinTTY(tt.tty) @@ -471,7 +469,7 @@ func TestDeleteRun(t *testing.T) { } defer reg.Verify(t) - err := deleteRun(&tt.opts) + err := deleteRun(context.Background(), &tt.opts) if tt.wantErr { if tt.wantErrMsg != "" { assert.EqualError(t, err, tt.wantErrMsg) diff --git a/pkg/cmd/cache/list/list.go b/pkg/cmd/cache/list/list.go index d699e2c3d9a..a9d082e6240 100644 --- a/pkg/cmd/cache/list/list.go +++ b/pkg/cmd/cache/list/list.go @@ -1,14 +1,14 @@ package list import ( + "context" "fmt" - "net/http" "strings" "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/cache/shared" @@ -19,7 +19,7 @@ import ( type ListOptions struct { BaseRepo func() (ghrepo.Interface, error) - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Exporter cmdutil.Exporter Now time.Time @@ -34,7 +34,7 @@ type ListOptions struct { func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := ListOptions{ IO: f.IOStreams, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -73,7 +73,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return runF(&opts) } - return listRun(&opts) + return listRun(cmd.Context(), &opts) }, } @@ -87,21 +87,20 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return cmd } -func listRun(opts *ListOptions) error { +func listRun(ctx context.Context, opts *ListOptions) error { repo, err := opts.BaseRepo() if err != nil { return err } - httpClient, err := opts.HttpClient() + client, err := opts.GitHubREST(repo.RepoHost()) if err != nil { return err } - client := api.NewClientFromHTTP(httpClient) cs := opts.IO.ColorScheme() opts.IO.StartProgressIndicator() - result, err := shared.GetCaches(client, repo, shared.GetCachesOptions{Limit: opts.Limit, Sort: opts.Sort, Order: opts.Order, Key: opts.Key, Ref: opts.Ref}) + result, err := shared.GetCaches(ctx, client, repo, shared.GetCachesOptions{Limit: opts.Limit, Sort: opts.Sort, Order: opts.Order, Key: opts.Key, Ref: opts.Ref}) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("%s Failed to get caches: %w", cs.FailureIcon(), err) diff --git a/pkg/cmd/cache/list/list_test.go b/pkg/cmd/cache/list/list_test.go index d4810cbcce5..47f065ba745 100644 --- a/pkg/cmd/cache/list/list_test.go +++ b/pkg/cmd/cache/list/list_test.go @@ -2,6 +2,7 @@ package list import ( "bytes" + "context" "fmt" "net/http" "testing" @@ -319,9 +320,7 @@ ID KEY SIZE CREATED ACCESSED if tt.stubs != nil { tt.stubs(reg) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.tty) ios.SetStdinTTY(tt.tty) @@ -333,7 +332,7 @@ ID KEY SIZE CREATED ACCESSED } defer reg.Verify(t) - err := listRun(&tt.opts) + err := listRun(context.Background(), &tt.opts) if tt.wantErr { if tt.wantErrMsg != "" { assert.EqualError(t, err, tt.wantErrMsg) diff --git a/pkg/cmd/cache/shared/shared.go b/pkg/cmd/cache/shared/shared.go index 5d7a4996f13..1e915160486 100644 --- a/pkg/cmd/cache/shared/shared.go +++ b/pkg/cmd/cache/shared/shared.go @@ -1,11 +1,13 @@ package shared import ( + "context" + "net/http" "strconv" "time" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" ) @@ -45,7 +47,7 @@ type GetCachesOptions struct { // Return a list of caches for a repository. Pass a negative limit to request // all pages from the API until all caches have been fetched. -func GetCaches(client *api.Client, repo ghrepo.Interface, opts GetCachesOptions) (*CachePayload, error) { +func GetCaches(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, opts GetCachesOptions) (*CachePayload, error) { u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "caches") if err != nil { return nil, err @@ -75,11 +77,15 @@ func GetCaches(client *api.Client, repo ghrepo.Interface, opts GetCachesOptions) pagination: for pageURL.String() != "" { var response CachePayload - next, err := client.RESTWithNext(repo.RepoHost(), "GET", pageURL.String(), nil, &response) + req, err := client.NewRequest(ctx, http.MethodGet, pageURL.String(), nil) if err != nil { return nil, err } - pageURL = safeurl.NewImmutableSafeURL(next) + resp, err := client.Do(req, &response) + if err != nil { + return nil, err + } + pageURL = safeurl.NewImmutableSafeURL(resp.NextPage()) if result == nil { result = &response diff --git a/pkg/cmd/cache/shared/shared_test.go b/pkg/cmd/cache/shared/shared_test.go index b9afc3e6bc1..2167791f917 100644 --- a/pkg/cmd/cache/shared/shared_test.go +++ b/pkg/cmd/cache/shared/shared_test.go @@ -2,8 +2,8 @@ package shared import ( "bytes" + "context" "encoding/json" - "net/http" "strings" "testing" @@ -11,7 +11,6 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" ) @@ -62,11 +61,11 @@ func TestGetCaches(t *testing.T) { t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} tt.stubs(reg) - httpClient := &http.Client{Transport: reg} - client := api.NewClientFromHTTP(httpClient) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) repo, err := ghrepo.FromFullName("OWNER/REPO") assert.NoError(t, err) - result, err := GetCaches(client, repo, tt.opts) + result, err := GetCaches(context.Background(), client, repo, tt.opts) assert.NoError(t, err) assert.Equal(t, tt.wantsCount, len(result.ActionsCaches)) }) diff --git a/pkg/cmd/gist/delete/delete.go b/pkg/cmd/gist/delete/delete.go index 0143a409eeb..9d858ad503c 100644 --- a/pkg/cmd/gist/delete/delete.go +++ b/pkg/cmd/gist/delete/delete.go @@ -1,13 +1,13 @@ package delete import ( + "context" "errors" "fmt" "net/http" "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" @@ -22,6 +22,7 @@ type DeleteOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Prompter prompter.Prompter Selector string @@ -33,6 +34,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Prompter: f.Prompter, } @@ -70,14 +72,14 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co if runF != nil { return runF(&opts) } - return deleteRun(&opts) + return deleteRun(c.Context(), &opts) }, } cmd.Flags().BoolVar(&opts.Confirmed, "yes", false, "Confirm deletion without prompting") return cmd } -func deleteRun(opts *DeleteOptions) error { +func deleteRun(ctx context.Context, opts *DeleteOptions) error { client, err := opts.HttpClient() if err != nil { return err @@ -90,6 +92,11 @@ func deleteRun(opts *DeleteOptions) error { host, _ := cfg.Authentication().DefaultHost() + restClient, err := opts.GitHubREST(host) + if err != nil { + return err + } + gistID := opts.Selector if strings.Contains(gistID, "/") { id, err := shared.GistIDFromURL(gistID) @@ -104,7 +111,7 @@ func deleteRun(opts *DeleteOptions) error { if gistID == "" { gist, err = shared.PromptGists(opts.Prompter, client, host, cs) } else { - gist, err = shared.GetGist(client, host, gistID) + gist, err = shared.GetGist(ctx, restClient, gistID) } if err != nil { return err @@ -124,8 +131,7 @@ func deleteRun(opts *DeleteOptions) error { } } - apiClient := api.NewClientFromHTTP(client) - if err := deleteGist(apiClient, host, gist.ID); err != nil { + if err := deleteGist(ctx, restClient, gist.ID); err != nil { if errors.Is(err, shared.NotFoundErr) { return fmt.Errorf("unable to delete gist %q: either the gist is not found or it is not owned by you", gist.Filename()) } @@ -143,13 +149,16 @@ func deleteRun(opts *DeleteOptions) error { return nil } -func deleteGist(apiClient *api.Client, hostname string, gistID string) error { +func deleteGist(ctx context.Context, client *githubrest.Client, gistID string) error { path, err := safeurl.JoinPath("gists", gistID) if err != nil { return err } - err = apiClient.REST(hostname, "DELETE", path.String(), nil, nil) + req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), nil) if err != nil { + return err + } + if _, err := client.Do(req, nil); err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { return shared.NotFoundErr diff --git a/pkg/cmd/gist/delete/delete_test.go b/pkg/cmd/gist/delete/delete_test.go index 2c4df8d8d6f..f44512f7d3f 100644 --- a/pkg/cmd/gist/delete/delete_test.go +++ b/pkg/cmd/gist/delete/delete_test.go @@ -2,12 +2,12 @@ package delete import ( "bytes" + "context" "fmt" "net/http" "testing" "time" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/prompter" @@ -301,6 +301,8 @@ func Test_deleteRun(t *testing.T) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) + tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -310,7 +312,7 @@ func Test_deleteRun(t *testing.T) { tt.opts.IO = ios t.Run(tt.name, func(t *testing.T) { - err := deleteRun(tt.opts) + err := deleteRun(context.Background(), tt.opts) reg.Verify(t) if tt.wantErr { assert.Error(t, err) @@ -380,9 +382,10 @@ func Test_gistDelete(t *testing.T) { t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} tt.httpStubs(reg) - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)(tt.hostname) + require.NoError(t, err) - err := deleteGist(client, tt.hostname, tt.gistID) + err = deleteGist(context.Background(), client, tt.gistID) if tt.wantErrString == "" && tt.wantErr == nil { require.NoError(t, err) } else { diff --git a/pkg/cmd/gist/edit/edit.go b/pkg/cmd/gist/edit/edit.go index 8195c6aa0f9..7c2799573dd 100644 --- a/pkg/cmd/gist/edit/edit.go +++ b/pkg/cmd/gist/edit/edit.go @@ -2,6 +2,7 @@ package edit import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -15,6 +16,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/gist/shared" @@ -29,6 +31,7 @@ var editNextOptions = []string{"Edit another file", "Submit", "Cancel"} type EditOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Config func() (gh.Config, error) Prompter prompter.Prompter @@ -46,6 +49,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman opts := EditOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, Prompter: f.Prompter, Edit: func(editorCmd, filename, defaultContent string, io *iostreams.IOStreams) (string, error) { @@ -100,7 +104,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman return runF(&opts) } - return editRun(&opts) + return editRun(c.Context(), &opts) }, } @@ -115,7 +119,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman return cmd } -func editRun(opts *EditOptions) error { +func editRun(ctx context.Context, opts *EditOptions) error { client, err := opts.HttpClient() if err != nil { return err @@ -128,6 +132,11 @@ func editRun(opts *EditOptions) error { host, _ := cfg.Authentication().DefaultHost() + restClient, err := opts.GitHubREST(host) + if err != nil { + return err + } + gistID := opts.Selector if gistID == "" { cs := opts.IO.ColorScheme() @@ -159,7 +168,7 @@ func editRun(opts *EditOptions) error { apiClient := api.NewClientFromHTTP(client) - gist, err := shared.GetGist(client, host, gistID) + gist, err := shared.GetGist(ctx, restClient, gistID) if err != nil { if errors.Is(err, shared.NotFoundErr) { return fmt.Errorf("gist not found: %s", gistID) @@ -233,7 +242,7 @@ func editRun(opts *EditOptions) error { } gistToUpdate.Files = files - return updateGist(apiClient, host, gistToUpdate) + return updateGist(ctx, restClient, gistToUpdate) } // Remove a file from the gist @@ -244,7 +253,7 @@ func editRun(opts *EditOptions) error { } gistToUpdate.Files = files - return updateGist(apiClient, host, gistToUpdate) + return updateGist(ctx, restClient, gistToUpdate) } filesToUpdate := map[string]string{} @@ -375,7 +384,7 @@ func editRun(opts *EditOptions) error { } gistToUpdate.Files = updatedFiles - return updateGist(apiClient, host, gistToUpdate) + return updateGist(ctx, restClient, gistToUpdate) } // https://docs.github.com/en/rest/gists/gists?apiVersion=2022-11-28#update-a-gist @@ -396,7 +405,7 @@ type gistFileToUpdate struct { NewFilename string `json:"filename,omitempty"` } -func updateGist(apiClient *api.Client, hostname string, gist gistToUpdate) error { +func updateGist(ctx context.Context, client *githubrest.Client, gist gistToUpdate) error { requestByte, err := json.Marshal(gist) if err != nil { return err @@ -409,10 +418,13 @@ func updateGist(apiClient *api.Client, hostname string, gist gistToUpdate) error if err != nil { return err } - err = apiClient.REST(hostname, "POST", path.String(), requestBody, &result) + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), requestBody) if err != nil { return err } + if _, err := client.Do(req, &result); err != nil { + return err + } return nil } diff --git a/pkg/cmd/gist/edit/edit_test.go b/pkg/cmd/gist/edit/edit_test.go index ac1555f5c24..e1d2d78b933 100644 --- a/pkg/cmd/gist/edit/edit_test.go +++ b/pkg/cmd/gist/edit/edit_test.go @@ -2,6 +2,7 @@ package edit import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -792,6 +793,7 @@ func Test_editRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, stdin, stdout, stderr := iostreams.Test() stdin.WriteString(tt.stdin) ios.SetStdoutTTY(tt.isTTY) @@ -810,7 +812,7 @@ func Test_editRun(t *testing.T) { } tt.opts.Prompter = pm - err := editRun(tt.opts) + err := editRun(context.Background(), tt.opts) reg.Verify(t) if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) diff --git a/pkg/cmd/gist/rename/rename.go b/pkg/cmd/gist/rename/rename.go index d5ef2d9199c..befa50b861a 100644 --- a/pkg/cmd/gist/rename/rename.go +++ b/pkg/cmd/gist/rename/rename.go @@ -2,6 +2,7 @@ package rename import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -11,6 +12,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/gist/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -22,6 +24,7 @@ type RenameOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Selector string OldFileName string @@ -32,6 +35,7 @@ func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Co opts := &RenameOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, } @@ -49,14 +53,14 @@ func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Co return runf(opts) } - return renameRun(opts) + return renameRun(cmd.Context(), opts) }, } return cmd } -func renameRun(opts *RenameOptions) error { +func renameRun(ctx context.Context, opts *RenameOptions) error { gistID := opts.Selector if strings.Contains(gistID, "/") { @@ -81,7 +85,12 @@ func renameRun(opts *RenameOptions) error { host, _ := cfg.Authentication().DefaultHost() - gist, err := shared.GetGist(client, host, gistID) + restClient, err := opts.GitHubREST(host) + if err != nil { + return err + } + + gist, err := shared.GetGist(ctx, restClient, gistID) if err != nil { if errors.Is(err, shared.NotFoundErr) { return fmt.Errorf("gist not found: %s", gistID) @@ -111,10 +120,10 @@ func renameRun(opts *RenameOptions) error { gist.Files[opts.NewFileName].Filename = opts.NewFileName gist.Files[opts.OldFileName] = &shared.GistFile{} - return updateGist(apiClient, host, gist) + return updateGist(ctx, restClient, gist) } -func updateGist(apiClient *api.Client, hostname string, gist *shared.Gist) error { +func updateGist(ctx context.Context, client *githubrest.Client, gist *shared.Gist) error { body := shared.Gist{ Description: gist.Description, Files: gist.Files, @@ -134,11 +143,14 @@ func updateGist(apiClient *api.Client, hostname string, gist *shared.Gist) error result := shared.Gist{} - err = apiClient.REST(hostname, "POST", path.String(), requestBody, &result) - + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), requestBody) if err != nil { return err } + if _, err := client.Do(req, &result); err != nil { + return err + } + return nil } diff --git a/pkg/cmd/gist/rename/rename_test.go b/pkg/cmd/gist/rename/rename_test.go index e835cc8a72b..4a8206d9aa3 100644 --- a/pkg/cmd/gist/rename/rename_test.go +++ b/pkg/cmd/gist/rename/rename_test.go @@ -2,6 +2,7 @@ package rename import ( "bytes" + "context" "net/http" "testing" @@ -157,6 +158,7 @@ func TestRenameRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, stdin, stdout, stderr := iostreams.Test() stdin.WriteString(tt.stdin) ios.SetStdoutTTY(!tt.nontty) @@ -172,7 +174,7 @@ func TestRenameRun(t *testing.T) { } t.Run(tt.name, func(t *testing.T) { - err := renameRun(tt.opts) + err := renameRun(context.Background(), tt.opts) reg.Verify(t) if tt.wantOut != "" { assert.EqualError(t, err, tt.wantOut) diff --git a/pkg/cmd/gist/shared/shared.go b/pkg/cmd/gist/shared/shared.go index efbf2e3ed80..e55a10a742a 100644 --- a/pkg/cmd/gist/shared/shared.go +++ b/pkg/cmd/gist/shared/shared.go @@ -1,6 +1,7 @@ package shared import ( + "context" "errors" "fmt" "io" @@ -62,16 +63,18 @@ func (g Gist) TruncDescription() string { var NotFoundErr = errors.New("not found") -func GetGist(client *http.Client, hostname, gistID string) (*Gist, error) { +func GetGist(ctx context.Context, client *githubrest.Client, gistID string) (*Gist, error) { gist := Gist{} path, err := safeurl.JoinPath("gists", gistID) if err != nil { return nil, err } - apiClient := api.NewClientFromHTTP(client) - err = apiClient.REST(hostname, "GET", path.String(), nil, &gist) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) if err != nil { + return nil, err + } + if _, err := client.Do(req, &gist); err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == 404 { return nil, NotFoundErr @@ -270,7 +273,7 @@ func GetRawGistFile(httpClient *http.Client, rawURL safeurl.SafeURL) (iostreams. defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return iostreams.Untrusted{}, api.HandleHTTPError(resp) + return iostreams.Untrusted{}, githubrest.NewErrorResponse(resp) } body, err := io.ReadAll(resp.Body) diff --git a/pkg/cmd/gist/view/view.go b/pkg/cmd/gist/view/view.go index 51ec38d0443..706ae733921 100644 --- a/pkg/cmd/gist/view/view.go +++ b/pkg/cmd/gist/view/view.go @@ -1,6 +1,7 @@ package view import ( + "context" "errors" "fmt" "net/http" @@ -9,6 +10,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" @@ -27,6 +29,7 @@ type ViewOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Browser browser Prompter prompter.Prompter @@ -44,6 +47,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Browser: f.Browser, Prompter: f.Prompter, } @@ -65,7 +69,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return viewRun(opts) + return viewRun(cmd.Context(), opts) }, } @@ -78,7 +82,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman return cmd } -func viewRun(opts *ViewOptions) error { +func viewRun(ctx context.Context, opts *ViewOptions) error { gistID := opts.Selector if opts.AllowEscapeSequences { @@ -97,6 +101,11 @@ func viewRun(opts *ViewOptions) error { hostname, _ := cfg.Authentication().DefaultHost() + restClient, err := opts.GitHubREST(hostname) + if err != nil { + return err + } + cs := opts.IO.ColorScheme() if gistID == "" { if !opts.IO.CanPrompt() { @@ -134,7 +143,7 @@ func viewRun(opts *ViewOptions) error { gistID = id } - gist, err := shared.GetGist(client, hostname, gistID) + gist, err := shared.GetGist(ctx, restClient, gistID) if err != nil { return err } diff --git a/pkg/cmd/gist/view/view_test.go b/pkg/cmd/gist/view/view_test.go index dcfe561ef66..2d993f537ea 100644 --- a/pkg/cmd/gist/view/view_test.go +++ b/pkg/cmd/gist/view/view_test.go @@ -2,6 +2,7 @@ package view import ( "bytes" + "context" "fmt" "net/http" "testing" @@ -225,7 +226,7 @@ func Test_viewRun(t *testing.T) { wantOut: "danger\x1b[31m\n", }, { - name: "piped inline file escapes are already neutralized by the JSON transport", + name: "piped inline file with escape sequences is refused", isTTY: false, opts: &ViewOptions{ Selector: "1234", @@ -239,7 +240,7 @@ func Test_viewRun(t *testing.T) { }, }, }, - wantOut: "danger^[[31m\n", + wantErr: "gist file contains terminal escape sequences; pass --allow-escape-sequences to view it anyway", }, { name: "one file, no ID supplied", @@ -592,6 +593,8 @@ func Test_viewRun(t *testing.T) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) + tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -604,7 +607,7 @@ func Test_viewRun(t *testing.T) { tt.opts.IO = ios t.Run(tt.name, func(t *testing.T) { - err := viewRun(tt.opts) + err := viewRun(context.Background(), tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) return diff --git a/pkg/cmd/gpg-key/add/add.go b/pkg/cmd/gpg-key/add/add.go index 35ee1273622..7637155a4ff 100644 --- a/pkg/cmd/gpg-key/add/add.go +++ b/pkg/cmd/gpg-key/add/add.go @@ -1,14 +1,15 @@ package add import ( + "context" "errors" "fmt" "io" - "net/http" "os" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -17,7 +18,7 @@ import ( type AddOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) - HTTPClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) KeyFile string Title string @@ -25,7 +26,7 @@ type AddOptions struct { func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command { opts := &AddOptions{ - HTTPClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, IO: f.IOStreams, } @@ -47,7 +48,7 @@ func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command if runF != nil { return runF(opts) } - return runAdd(opts) + return runAdd(cmd.Context(), opts) }, } @@ -55,12 +56,7 @@ func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command return cmd } -func runAdd(opts *AddOptions) error { - httpClient, err := opts.HTTPClient() - if err != nil { - return err - } - +func runAdd(ctx context.Context, opts *AddOptions) error { var keyReader io.Reader if opts.KeyFile == "-" { keyReader = opts.IO.In @@ -81,7 +77,12 @@ func runAdd(opts *AddOptions) error { hostname, _ := cfg.Authentication().DefaultHost() - err = gpgKeyUpload(httpClient, hostname, keyReader, opts.Title) + client, err := opts.GitHubREST(hostname) + if err != nil { + return err + } + + err = gpgKeyUpload(ctx, client, keyReader, opts.Title) if err != nil { cs := opts.IO.ColorScheme() if errors.Is(err, errScopesMissing) { diff --git a/pkg/cmd/gpg-key/add/add_test.go b/pkg/cmd/gpg-key/add/add_test.go index e2f9a879e26..dcaf7b35d60 100644 --- a/pkg/cmd/gpg-key/add/add_test.go +++ b/pkg/cmd/gpg-key/add/add_test.go @@ -1,6 +1,7 @@ package add import ( + "context" "net/http" "strings" "testing" @@ -23,7 +24,9 @@ func Test_gpgKeyUploadScopesMissing(t *testing.T) { httpmock.StatusStringResponse(http.StatusNotFound, `{"message":"Not Found"}`), ) - err := gpgKeyUpload(&http.Client{Transport: reg}, "github.com", strings.NewReader("-----BEGIN PGP PUBLIC KEY BLOCK-----"), "") + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + err = gpgKeyUpload(context.Background(), client, strings.NewReader("-----BEGIN PGP PUBLIC KEY BLOCK-----"), "") require.Same(t, errScopesMissing, err) } @@ -44,7 +47,9 @@ func Test_gpgKeyUploadDuplicateKeyBeforeWrongFormat(t *testing.T) { }`), "Content-Type", "application/json"), ) - err := gpgKeyUpload(&http.Client{Transport: reg}, "github.com", strings.NewReader("binary-key"), "") + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + err = gpgKeyUpload(context.Background(), client, strings.NewReader("binary-key"), "") require.Same(t, errDuplicateKey, err) } @@ -64,7 +69,9 @@ func Test_gpgKeyUploadWrongFormat(t *testing.T) { }`), ) - err := gpgKeyUpload(&http.Client{Transport: reg}, "github.com", strings.NewReader("binary-key"), "") + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + err = gpgKeyUpload(context.Background(), client, strings.NewReader("binary-key"), "") require.Same(t, errWrongFormat, err) } @@ -80,7 +87,9 @@ func Test_gpgKeyUploadHTTPError(t *testing.T) { ), ) - err := gpgKeyUpload(&http.Client{Transport: reg}, "github.com", strings.NewReader("-----BEGIN PGP PUBLIC KEY BLOCK-----"), "") + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + err = gpgKeyUpload(context.Background(), client, strings.NewReader("-----BEGIN PGP PUBLIC KEY BLOCK-----"), "") var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) @@ -197,9 +206,7 @@ func Test_runAdd(t *testing.T) { reg := &httpmock.Registry{} tt.opts.IO = ios - tt.opts.HTTPClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) if tt.httpStubs != nil { tt.httpStubs(reg) } @@ -209,7 +216,7 @@ func Test_runAdd(t *testing.T) { t.Run(tt.name, func(t *testing.T) { defer reg.Verify(t) - err := runAdd(&tt.opts) + err := runAdd(context.Background(), &tt.opts) if tt.wantErrMsg != "" { assert.Equal(t, tt.wantErrMsg, err.Error()) } else { diff --git a/pkg/cmd/gpg-key/add/http.go b/pkg/cmd/gpg-key/add/http.go index 634cc640619..0e156f51447 100644 --- a/pkg/cmd/gpg-key/add/http.go +++ b/pkg/cmd/gpg-key/add/http.go @@ -2,12 +2,12 @@ package add import ( "bytes" + "context" "encoding/json" "errors" "io" "net/http" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -16,7 +16,7 @@ var errScopesMissing = errors.New("insufficient OAuth scopes") var errDuplicateKey = errors.New("key already exists") var errWrongFormat = errors.New("key in wrong format") -func gpgKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) error { +func gpgKeyUpload(ctx context.Context, client *githubrest.Client, keyFile io.Reader, title string) error { keyBytes, err := io.ReadAll(keyFile) if err != nil { return err @@ -39,11 +39,11 @@ func gpgKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t return err } - // 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 - apiClient := api.NewClientFromHTTP(httpClient) - err = apiClient.REST(hostname, "POST", path.String(), bytes.NewBuffer(payloadBytes), nil) + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), bytes.NewBuffer(payloadBytes)) + if err != nil { + return err + } + _, err = client.Do(req, nil) if err != nil { if httpError, ok := errors.AsType[*githubrest.ErrorResponse](err); ok { if httpError.StatusCode == 404 { diff --git a/pkg/cmd/gpg-key/delete/delete.go b/pkg/cmd/gpg-key/delete/delete.go index 7ca17de8119..b2c75f7d723 100644 --- a/pkg/cmd/gpg-key/delete/delete.go +++ b/pkg/cmd/gpg-key/delete/delete.go @@ -1,11 +1,12 @@ package delete import ( + "context" "fmt" - "net/http" "strconv" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -15,7 +16,7 @@ import ( type DeleteOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) KeyID string Confirmed bool @@ -24,7 +25,7 @@ type DeleteOptions struct { func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { opts := &DeleteOptions{ - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, IO: f.IOStreams, Prompter: f.Prompter, @@ -44,7 +45,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co if runF != nil { return runF(opts) } - return deleteRun(opts) + return deleteRun(cmd.Context(), opts) }, } @@ -54,19 +55,20 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return cmd } -func deleteRun(opts *DeleteOptions) error { - httpClient, err := opts.HttpClient() +func deleteRun(ctx context.Context, opts *DeleteOptions) error { + cfg, err := opts.Config() if err != nil { return err } - cfg, err := opts.Config() + host, _ := cfg.Authentication().DefaultHost() + + client, err := opts.GitHubREST(host) if err != nil { return err } - host, _ := cfg.Authentication().DefaultHost() - gpgKeys, err := getGPGKeys(httpClient, host) + gpgKeys, err := getGPGKeys(ctx, client) if err != nil { return err } @@ -89,7 +91,7 @@ func deleteRun(opts *DeleteOptions) error { } } - err = deleteGPGKey(httpClient, host, id) + err = deleteGPGKey(ctx, client, id) if err != nil { return err } diff --git a/pkg/cmd/gpg-key/delete/delete_test.go b/pkg/cmd/gpg-key/delete/delete_test.go index 1e1df4ba1a4..922e3dc2d9d 100644 --- a/pkg/cmd/gpg-key/delete/delete_test.go +++ b/pkg/cmd/gpg-key/delete/delete_test.go @@ -2,6 +2,7 @@ package delete import ( "bytes" + "context" "net/http" "net/url" "testing" @@ -30,7 +31,9 @@ func Test_deleteGPGKeyHTTPError(t *testing.T) { ), ) - err := deleteGPGKey(&http.Client{Transport: reg}, "github.com", "123") + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + err = deleteGPGKey(context.Background(), client, "123") var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) @@ -50,7 +53,9 @@ func Test_getGPGKeysHTTPError(t *testing.T) { ), ) - keys, err := getGPGKeys(&http.Client{Transport: reg}, "github.com") + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + keys, err := getGPGKeys(context.Background(), client) assert.Nil(t, keys) var httpErr *githubrest.ErrorResponse @@ -243,9 +248,7 @@ func Test_deleteRun(t *testing.T) { tt.httpStubs(reg) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -255,7 +258,7 @@ func Test_deleteRun(t *testing.T) { tt.opts.IO = ios t.Run(tt.name, func(t *testing.T) { - err := deleteRun(&tt.opts) + err := deleteRun(context.Background(), &tt.opts) reg.Verify(t) if tt.wantErr { assert.Error(t, err) diff --git a/pkg/cmd/gpg-key/delete/http.go b/pkg/cmd/gpg-key/delete/http.go index af4f15a4557..ff10b40755b 100644 --- a/pkg/cmd/gpg-key/delete/http.go +++ b/pkg/cmd/gpg-key/delete/http.go @@ -1,9 +1,10 @@ package delete import ( + "context" "net/http" - "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -12,18 +13,20 @@ type gpgKey struct { KeyID string `json:"key_id"` } -func deleteGPGKey(httpClient *http.Client, host, id string) error { +func deleteGPGKey(ctx context.Context, client *githubrest.Client, id string) error { path, err := safeurl.JoinPath("user", "gpg_keys", id) if err != nil { return err } - // 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", path.String(), nil, nil) + req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), nil) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err } -func getGPGKeys(httpClient *http.Client, host string) ([]gpgKey, error) { +func getGPGKeys(ctx context.Context, client *githubrest.Client) ([]gpgKey, error) { u, err := safeurl.JoinPath("user", "gpg_keys") if err != nil { return nil, err @@ -31,12 +34,12 @@ func getGPGKeys(httpClient *http.Client, host string) ([]gpgKey, error) { u.SetQuery("per_page", "100") var keys []gpgKey - // 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(host, "GET", u.String(), nil, &keys) + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } + if _, err := client.Do(req, &keys); err != nil { + return nil, err + } return keys, nil } diff --git a/pkg/cmd/gpg-key/list/http.go b/pkg/cmd/gpg-key/list/http.go index dd9afef0dd6..fea6676d687 100644 --- a/pkg/cmd/gpg-key/list/http.go +++ b/pkg/cmd/gpg-key/list/http.go @@ -1,12 +1,12 @@ package list import ( + "context" "errors" "net/http" "strings" "time" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -35,7 +35,7 @@ type gpgKey struct { ExpiresAt time.Time `json:"expires_at"` } -func userKeys(httpClient *http.Client, host, userHandle string) ([]gpgKey, error) { +func userKeys(ctx context.Context, client *githubrest.Client, userHandle string) ([]gpgKey, error) { u, err := safeurl.JoinPath("user", "gpg_keys") if err != nil { return nil, err @@ -49,11 +49,11 @@ func userKeys(httpClient *http.Client, host, userHandle string) ([]gpgKey, error u.SetQuery("per_page", "100") var keys []gpgKey - // 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(host, "GET", u.String(), nil, &keys) + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { + return nil, err + } + if _, err := client.Do(req, &keys); err != nil { if httpErr, ok := errors.AsType[*githubrest.ErrorResponse](err); ok && httpErr.StatusCode == 404 { return nil, errScopes } diff --git a/pkg/cmd/gpg-key/list/list.go b/pkg/cmd/gpg-key/list/list.go index 4244ba34971..7f17dcebf99 100644 --- a/pkg/cmd/gpg-key/list/list.go +++ b/pkg/cmd/gpg-key/list/list.go @@ -1,12 +1,13 @@ package list import ( + "context" "errors" "fmt" - "net/http" "time" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -16,14 +17,14 @@ import ( type ListOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) - HTTPClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ IO: f.IOStreams, Config: f.Config, - HTTPClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -35,27 +36,27 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return listRun(opts) + return listRun(cmd.Context(), opts) }, } return cmd } -func listRun(opts *ListOptions) error { - apiClient, err := opts.HTTPClient() +func listRun(ctx context.Context, opts *ListOptions) error { + cfg, err := opts.Config() if err != nil { return err } - cfg, err := opts.Config() + host, _ := cfg.Authentication().DefaultHost() + + client, err := opts.GitHubREST(host) if err != nil { return err } - host, _ := cfg.Authentication().DefaultHost() - - gpgKeys, err := userKeys(apiClient, host, "") + gpgKeys, err := userKeys(ctx, client, "") if err != nil { if errors.Is(err, errScopes) { cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/gpg-key/list/list_test.go b/pkg/cmd/gpg-key/list/list_test.go index 71e9f121140..ca536734403 100644 --- a/pkg/cmd/gpg-key/list/list_test.go +++ b/pkg/cmd/gpg-key/list/list_test.go @@ -1,6 +1,7 @@ package list import ( + "context" "fmt" "net/http" "net/url" @@ -25,7 +26,9 @@ func Test_userKeysScopesMissing(t *testing.T) { httpmock.StatusStringResponse(http.StatusNotFound, `{"message":"Not Found"}`), ) - keys, err := userKeys(&http.Client{Transport: reg}, "github.com", "") + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + keys, err := userKeys(context.Background(), client, "") assert.Nil(t, keys) require.Same(t, errScopes, err) @@ -42,7 +45,9 @@ func Test_userKeysHTTPError(t *testing.T) { ), ) - keys, err := userKeys(&http.Client{Transport: reg}, "github.com", "") + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + keys, err := userKeys(context.Background(), client, "") assert.Nil(t, keys) var httpErr *githubrest.ErrorResponse @@ -60,7 +65,9 @@ func Test_userKeysForUser(t *testing.T) { httpmock.StringResponse(`[{"key_id":"ABC123"}]`), ) - keys, err := userKeys(&http.Client{Transport: reg}, "github.com", "monalisa") + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + keys, err := userKeys(context.Background(), client, "monalisa") require.NoError(t, err) require.Len(t, keys, 1) @@ -78,7 +85,7 @@ func Test_listRun(t *testing.T) { }{ { name: "list tty", - opts: ListOptions{HTTPClient: func() (*http.Client, error) { + opts: ListOptions{GitHubREST: func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { createdAt := time.Now().Add(time.Duration(-24) * time.Hour) expiresAt, _ := time.Parse(time.RFC3339, "2099-01-01T15:44:24+01:00") noExpires := time.Time{} @@ -106,7 +113,7 @@ func Test_listRun(t *testing.T) { expiresAt.Format(time.RFC3339), noExpires.Format(time.RFC3339))), ) - return &http.Client{Transport: reg}, nil + return httpmock.RESTClientFunc(reg)(host, opts...) }}, isTTY: true, wantStdout: heredoc.Doc(` @@ -118,7 +125,7 @@ func Test_listRun(t *testing.T) { }, { name: "list non-tty", - opts: ListOptions{HTTPClient: func() (*http.Client, error) { + opts: ListOptions{GitHubREST: func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { createdAt1, _ := time.Parse(time.RFC3339, "2020-06-11T15:44:24+01:00") expiresAt, _ := time.Parse(time.RFC3339, "2099-01-01T15:44:24+01:00") createdAt2, _ := time.Parse(time.RFC3339, "2021-01-11T15:44:24+01:00") @@ -148,7 +155,7 @@ func Test_listRun(t *testing.T) { createdAt2.Format(time.RFC3339), noExpires.Format(time.RFC3339))), ) - return &http.Client{Transport: reg}, nil + return httpmock.RESTClientFunc(reg)(host, opts...) }}, isTTY: false, wantStdout: heredoc.Doc(` @@ -160,13 +167,13 @@ func Test_listRun(t *testing.T) { { name: "no keys", opts: ListOptions{ - HTTPClient: func() (*http.Client, error) { + GitHubREST: func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { reg := &httpmock.Registry{} reg.Register( httpmock.REST("GET", "user/gpg_keys"), httpmock.StringResponse(`[]`), ) - return &http.Client{Transport: reg}, nil + return httpmock.RESTClientFunc(reg)(host, opts...) }, }, wantStdout: "", @@ -185,7 +192,7 @@ func Test_listRun(t *testing.T) { opts := tt.opts opts.IO = ios opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } - err := listRun(&opts) + err := listRun(context.Background(), &opts) if tt.wantErr { assert.Error(t, err) } else { diff --git a/pkg/cmd/label/clone.go b/pkg/cmd/label/clone.go index b8c4631c61e..b033449044c 100644 --- a/pkg/cmd/label/clone.go +++ b/pkg/cmd/label/clone.go @@ -9,6 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -19,6 +20,7 @@ import ( type cloneOptions struct { BaseRepo func() (ghrepo.Interface, error) HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams SourceRepo ghrepo.Interface @@ -28,6 +30,7 @@ type cloneOptions struct { func newCmdClone(f *cmdutil.Factory, runF func(*cloneOptions) error) *cobra.Command { opts := cloneOptions{ HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, IO: f.IOStreams, } @@ -64,7 +67,7 @@ func newCmdClone(f *cmdutil.Factory, runF func(*cloneOptions) error) *cobra.Comm if runF != nil { return runF(&opts) } - return cloneRun(&opts) + return cloneRun(c.Context(), &opts) }, } @@ -73,7 +76,7 @@ func newCmdClone(f *cmdutil.Factory, runF func(*cloneOptions) error) *cobra.Comm return cmd } -func cloneRun(opts *cloneOptions) error { +func cloneRun(ctx context.Context, opts *cloneOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -84,8 +87,13 @@ func cloneRun(opts *cloneOptions) error { return err } + client, err := opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return err + } + opts.IO.StartProgressIndicator() - successCount, totalCount, err := cloneLabels(httpClient, baseRepo, opts) + successCount, totalCount, err := cloneLabels(ctx, httpClient, client, baseRepo, opts) opts.IO.StopProgressIndicator() if err != nil { return err @@ -109,9 +117,9 @@ func cloneRun(opts *cloneOptions) error { return nil } -func cloneLabels(client *http.Client, destination ghrepo.Interface, opts *cloneOptions) (successCount uint32, totalCount int, err error) { +func cloneLabels(ctx context.Context, httpClient *http.Client, client *githubrest.Client, destination ghrepo.Interface, opts *cloneOptions) (successCount uint32, totalCount int, err error) { successCount = 0 - labels, totalCount, err := listLabels(client, opts.SourceRepo, listQueryOptions{Limit: -1}) + labels, totalCount, err := listLabels(httpClient, opts.SourceRepo, listQueryOptions{Limit: -1}) if err != nil { return } @@ -119,18 +127,18 @@ func cloneLabels(client *http.Client, destination ghrepo.Interface, opts *cloneO workers := 10 toCreate := make(chan createOptions) - wg, ctx := errgroup.WithContext(context.Background()) + wg, groupCtx := errgroup.WithContext(ctx) for i := 0; i < workers; i++ { wg.Go(func() error { for { select { - case <-ctx.Done(): + case <-groupCtx.Done(): return nil case l, ok := <-toCreate: if !ok { return nil } - err := createLabel(client, destination, &l) + err := createLabel(ctx, client, destination, &l) if err != nil { if !errors.Is(err, errLabelAlreadyExists) { return err diff --git a/pkg/cmd/label/clone_test.go b/pkg/cmd/label/clone_test.go index 467ab5cacf1..910b97cc36a 100644 --- a/pkg/cmd/label/clone_test.go +++ b/pkg/cmd/label/clone_test.go @@ -2,6 +2,7 @@ package label import ( "bytes" + "context" "net/http" "testing" @@ -562,6 +563,7 @@ func TestCloneRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) io, _, stdout, _ := iostreams.Test() io.SetStdoutTTY(tt.tty) io.SetStdinTTY(tt.tty) @@ -571,7 +573,7 @@ func TestCloneRun(t *testing.T) { return ghrepo.New("OWNER", "REPO"), nil } - err := cloneRun(tt.opts) + err := cloneRun(context.Background(), tt.opts) if tt.wantErr { assert.EqualError(t, err, tt.wantErrMsg) diff --git a/pkg/cmd/label/create.go b/pkg/cmd/label/create.go index f63c3f83da8..ed1f6dd2efa 100644 --- a/pkg/cmd/label/create.go +++ b/pkg/cmd/label/create.go @@ -2,6 +2,7 @@ package label import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -11,7 +12,6 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" @@ -41,7 +41,7 @@ var randomColors = []string{ type createOptions struct { BaseRepo func() (ghrepo.Interface, error) - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Color string @@ -52,7 +52,7 @@ type createOptions struct { func newCmdCreate(f *cmdutil.Factory, runF func(*createOptions) error) *cobra.Command { opts := createOptions{ - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, IO: f.IOStreams, } @@ -79,7 +79,7 @@ func newCmdCreate(f *cmdutil.Factory, runF func(*createOptions) error) *cobra.Co if runF != nil { return runF(&opts) } - return createRun(&opts) + return createRun(c.Context(), &opts) }, } @@ -92,13 +92,13 @@ func newCmdCreate(f *cmdutil.Factory, runF func(*createOptions) error) *cobra.Co var errLabelAlreadyExists = errors.New("label already exists") -func createRun(opts *createOptions) error { - httpClient, err := opts.HttpClient() +func createRun(ctx context.Context, opts *createOptions) error { + baseRepo, err := opts.BaseRepo() if err != nil { return err } - baseRepo, err := opts.BaseRepo() + client, err := opts.GitHubREST(baseRepo.RepoHost()) if err != nil { return err } @@ -109,7 +109,7 @@ func createRun(opts *createOptions) error { } opts.IO.StartProgressIndicator() - err = createLabel(httpClient, baseRepo, opts) + err = createLabel(ctx, client, baseRepo, opts) opts.IO.StopProgressIndicator() if err != nil { if errors.Is(err, errLabelAlreadyExists) { @@ -127,8 +127,7 @@ func createRun(opts *createOptions) error { return nil } -func createLabel(client *http.Client, repo ghrepo.Interface, opts *createOptions) error { - apiClient := api.NewClientFromHTTP(client) +func createLabel(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, opts *createOptions) error { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "labels") if err != nil { return err @@ -141,8 +140,11 @@ func createLabel(client *http.Client, repo ghrepo.Interface, opts *createOptions if err != nil { return err } - requestBody := bytes.NewReader(requestByte) - err = apiClient.REST(repo.RepoHost(), "POST", path.String(), requestBody, nil) + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), bytes.NewReader(requestByte)) + if err != nil { + return err + } + _, err = client.Do(req, nil) if httpError, ok := err.(*githubrest.ErrorResponse); ok && isLabelAlreadyExistsError(httpError) { err = errLabelAlreadyExists @@ -154,13 +156,13 @@ func createLabel(client *http.Client, repo ghrepo.Interface, opts *createOptions Color: opts.Color, Name: opts.Name, } - return updateLabel(apiClient, repo, &editOpts) + return updateLabel(ctx, client, repo, &editOpts) } return err } -func updateLabel(apiClient *api.Client, repo ghrepo.Interface, opts *editOptions) error { +func updateLabel(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, opts *editOptions) error { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "labels", opts.Name) if err != nil { return err @@ -179,8 +181,11 @@ func updateLabel(apiClient *api.Client, repo ghrepo.Interface, opts *editOptions if err != nil { return err } - requestBody := bytes.NewReader(requestByte) - err = apiClient.REST(repo.RepoHost(), "PATCH", path.String(), requestBody, nil) + req, err := client.NewRequest(ctx, http.MethodPatch, path.String(), bytes.NewReader(requestByte)) + if err != nil { + return err + } + _, err = client.Do(req, nil) if httpError, ok := err.(*githubrest.ErrorResponse); ok && isLabelAlreadyExistsError(httpError) { err = errLabelAlreadyExists diff --git a/pkg/cmd/label/create_test.go b/pkg/cmd/label/create_test.go index bcf59aa2072..be163e11518 100644 --- a/pkg/cmd/label/create_test.go +++ b/pkg/cmd/label/create_test.go @@ -2,9 +2,9 @@ package label import ( "bytes" + "context" "encoding/json" "io" - "net/http" "testing" "github.com/cli/cli/v2/internal/ghrepo" @@ -141,9 +141,7 @@ func TestCreateRun(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, _ := iostreams.Test() ios.SetStdoutTTY(tt.tty) ios.SetStdinTTY(tt.tty) @@ -153,7 +151,7 @@ func TestCreateRun(t *testing.T) { return ghrepo.New("OWNER", "REPO"), nil } defer reg.Verify(t) - err := createRun(tt.opts) + err := createRun(context.Background(), tt.opts) assert.NoError(t, err) assert.Equal(t, tt.wantStdout, stdout.String()) diff --git a/pkg/cmd/label/delete.go b/pkg/cmd/label/delete.go index 8dd1532c127..1f47c5a7205 100644 --- a/pkg/cmd/label/delete.go +++ b/pkg/cmd/label/delete.go @@ -1,11 +1,12 @@ package label import ( + "context" "fmt" "net/http" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -18,7 +19,7 @@ type iprompter interface { type deleteOptions struct { BaseRepo func() (ghrepo.Interface, error) - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Prompter iprompter @@ -28,7 +29,7 @@ type deleteOptions struct { func newCmdDelete(f *cmdutil.Factory, runF func(*deleteOptions) error) *cobra.Command { opts := deleteOptions{ - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, IO: f.IOStreams, Prompter: f.Prompter, } @@ -49,7 +50,7 @@ func newCmdDelete(f *cmdutil.Factory, runF func(*deleteOptions) error) *cobra.Co if runF != nil { return runF(&opts) } - return deleteRun(&opts) + return deleteRun(c.Context(), &opts) }, } @@ -60,13 +61,13 @@ func newCmdDelete(f *cmdutil.Factory, runF func(*deleteOptions) error) *cobra.Co return cmd } -func deleteRun(opts *deleteOptions) error { - httpClient, err := opts.HttpClient() +func deleteRun(ctx context.Context, opts *deleteOptions) error { + baseRepo, err := opts.BaseRepo() if err != nil { return err } - baseRepo, err := opts.BaseRepo() + client, err := opts.GitHubREST(baseRepo.RepoHost()) if err != nil { return err } @@ -78,7 +79,7 @@ func deleteRun(opts *deleteOptions) error { } opts.IO.StartProgressIndicator() - err = deleteLabel(httpClient, baseRepo, opts.Name) + err = deleteLabel(ctx, client, baseRepo, opts.Name) opts.IO.StopProgressIndicator() if err != nil { return err @@ -93,12 +94,16 @@ func deleteRun(opts *deleteOptions) error { return nil } -func deleteLabel(client *http.Client, repo ghrepo.Interface, name string) error { - apiClient := api.NewClientFromHTTP(client) +func deleteLabel(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, name string) error { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "labels", name) if err != nil { return err } - return apiClient.REST(repo.RepoHost(), "DELETE", path.String(), nil, nil) + req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), nil) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err } diff --git a/pkg/cmd/label/delete_test.go b/pkg/cmd/label/delete_test.go index f43b8d5bd23..67a516e1961 100644 --- a/pkg/cmd/label/delete_test.go +++ b/pkg/cmd/label/delete_test.go @@ -2,7 +2,7 @@ package label import ( "bytes" - "net/http" + "context" "testing" "github.com/cli/cli/v2/internal/ghrepo" @@ -149,9 +149,7 @@ func TestDeleteRun(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) pm := &prompter.PrompterMock{} if tt.prompterStubs != nil { @@ -168,7 +166,7 @@ func TestDeleteRun(t *testing.T) { return ghrepo.New("OWNER", "REPO"), nil } defer reg.Verify(t) - err := deleteRun(tt.opts) + err := deleteRun(context.Background(), tt.opts) if tt.wantErr { assert.EqualError(t, err, tt.errMsg) diff --git a/pkg/cmd/label/edit.go b/pkg/cmd/label/edit.go index 79eb633f531..a5659154676 100644 --- a/pkg/cmd/label/edit.go +++ b/pkg/cmd/label/edit.go @@ -1,14 +1,14 @@ package label import ( + "context" "errors" "fmt" - "net/http" "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -16,7 +16,7 @@ import ( type editOptions struct { BaseRepo func() (ghrepo.Interface, error) - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Color string @@ -27,7 +27,7 @@ type editOptions struct { func newCmdEdit(f *cmdutil.Factory, runF func(*editOptions) error) *cobra.Command { opts := editOptions{ - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, IO: f.IOStreams, } @@ -61,7 +61,7 @@ func newCmdEdit(f *cmdutil.Factory, runF func(*editOptions) error) *cobra.Comman if runF != nil { return runF(&opts) } - return editRun(&opts) + return editRun(c.Context(), &opts) }, } @@ -72,20 +72,19 @@ func newCmdEdit(f *cmdutil.Factory, runF func(*editOptions) error) *cobra.Comman return cmd } -func editRun(opts *editOptions) error { - httpClient, err := opts.HttpClient() +func editRun(ctx context.Context, opts *editOptions) error { + baseRepo, err := opts.BaseRepo() if err != nil { return err } - apiClient := api.NewClientFromHTTP(httpClient) - baseRepo, err := opts.BaseRepo() + client, err := opts.GitHubREST(baseRepo.RepoHost()) if err != nil { return err } opts.IO.StartProgressIndicator() - err = updateLabel(apiClient, baseRepo, opts) + err = updateLabel(ctx, client, baseRepo, opts) opts.IO.StopProgressIndicator() if err != nil { if errors.Is(err, errLabelAlreadyExists) { diff --git a/pkg/cmd/label/edit_test.go b/pkg/cmd/label/edit_test.go index d5cb6104b54..dfa709d138f 100644 --- a/pkg/cmd/label/edit_test.go +++ b/pkg/cmd/label/edit_test.go @@ -2,7 +2,7 @@ package label import ( "bytes" - "net/http" + "context" "testing" "github.com/cli/cli/v2/internal/ghrepo" @@ -144,9 +144,7 @@ func TestEditRun(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) io, _, stdout, _ := iostreams.Test() io.SetStdoutTTY(tt.tty) io.SetStdinTTY(tt.tty) @@ -156,7 +154,7 @@ func TestEditRun(t *testing.T) { return ghrepo.New("OWNER", "REPO"), nil } defer reg.Verify(t) - err := editRun(tt.opts) + err := editRun(context.Background(), tt.opts) if tt.wantErrMsg != "" { assert.EqualError(t, err, tt.wantErrMsg) diff --git a/pkg/cmd/ruleset/check/check.go b/pkg/cmd/ruleset/check/check.go index 1fbbc432028..644df277ffb 100644 --- a/pkg/cmd/ruleset/check/check.go +++ b/pkg/cmd/ruleset/check/check.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" @@ -22,6 +23,7 @@ import ( type CheckOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Config func() (gh.Config, error) BaseRepo func() (ghrepo.Interface, error) @@ -38,6 +40,7 @@ func NewCmdCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Comm IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Browser: f.Browser, Git: f.GitClient, } @@ -92,7 +95,7 @@ func NewCmdCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Comm return runF(opts) } - return checkRun(opts) + return checkRun(cmd.Context(), opts) }, } @@ -102,7 +105,7 @@ func NewCmdCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra.Comm return cmd } -func checkRun(opts *CheckOptions) error { +func checkRun(ctx context.Context, opts *CheckOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -114,6 +117,11 @@ func checkRun(opts *CheckOptions) error { return fmt.Errorf("could not determine repo to use: %w", err) } + restClient, err := opts.GitHubREST(repoI.RepoHost()) + if err != nil { + return err + } + git := opts.Git if opts.Default { @@ -125,7 +133,7 @@ func checkRun(opts *CheckOptions) error { } if opts.Branch == "" { - opts.Branch, err = git.CurrentBranch(context.Background()) + opts.Branch, err = git.CurrentBranch(ctx) if err != nil { return fmt.Errorf("could not determine current branch: %w", err) } @@ -150,7 +158,11 @@ func checkRun(opts *CheckOptions) error { return err } - if err = client.REST(repoI.RepoHost(), "GET", endpoint.String(), nil, &rules); err != nil { + req, err := restClient.NewRequest(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return err + } + if _, err = restClient.Do(req, &rules); err != nil { return fmt.Errorf("GET %s failed: %w", endpoint.String(), err) } diff --git a/pkg/cmd/ruleset/check/check_test.go b/pkg/cmd/ruleset/check/check_test.go index b24e084c4d9..4ff5c00626d 100644 --- a/pkg/cmd/ruleset/check/check_test.go +++ b/pkg/cmd/ruleset/check/check_test.go @@ -2,6 +2,7 @@ package check import ( "bytes" + "context" "io" "net/http" "testing" @@ -207,13 +208,14 @@ func Test_checkRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: fakeHTTP}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(fakeHTTP) tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("my-org/repo-name") } browser := &browser.Stub{} tt.opts.Browser = browser - err := checkRun(&tt.opts) + err := checkRun(context.Background(), &tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) diff --git a/pkg/cmd/ruleset/view/http.go b/pkg/cmd/ruleset/view/http.go index c182917b12b..b2b2ec3b18e 100644 --- a/pkg/cmd/ruleset/view/http.go +++ b/pkg/cmd/ruleset/view/http.go @@ -1,38 +1,41 @@ package view import ( + "context" "net/http" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" ) -func viewRepoRuleset(httpClient *http.Client, repo ghrepo.Interface, databaseId string) (*shared.RulesetREST, error) { +func viewRepoRuleset(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, databaseId string) (*shared.RulesetREST, error) { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "rulesets", databaseId) if err != nil { return nil, err } - return viewRuleset(httpClient, repo.RepoHost(), path) + return viewRuleset(ctx, client, path) } -func viewOrgRuleset(httpClient *http.Client, orgLogin string, databaseId string, host string) (*shared.RulesetREST, error) { +func viewOrgRuleset(ctx context.Context, client *githubrest.Client, orgLogin string, databaseId string) (*shared.RulesetREST, error) { path, err := safeurl.JoinPath("orgs", orgLogin, "rulesets", databaseId) if err != nil { return nil, err } - return viewRuleset(httpClient, host, path) + return viewRuleset(ctx, client, path) } -func viewRuleset(httpClient *http.Client, hostname string, path safeurl.SafeURL) (*shared.RulesetREST, error) { - apiClient := api.NewClientFromHTTP(httpClient) +func viewRuleset(ctx context.Context, client *githubrest.Client, path safeurl.SafeURL) (*shared.RulesetREST, error) { result := shared.RulesetREST{} - err := apiClient.REST(hostname, "GET", path.String(), nil, &result) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) if err != nil { return nil, err } + if _, err := client.Do(req, &result); err != nil { + return nil, err + } return &result, nil } diff --git a/pkg/cmd/ruleset/view/view.go b/pkg/cmd/ruleset/view/view.go index 8cc43927c75..f48d57fab59 100644 --- a/pkg/cmd/ruleset/view/view.go +++ b/pkg/cmd/ruleset/view/view.go @@ -1,6 +1,7 @@ package view import ( + "context" "fmt" "net/http" "sort" @@ -10,6 +11,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/ruleset/shared" @@ -21,6 +23,7 @@ import ( type ViewOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser @@ -37,6 +40,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman opts := &ViewOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Browser: f.Browser, Prompter: f.Prompter, } @@ -96,7 +100,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return viewRun(opts) + return viewRun(cmd.Context(), opts) }, } @@ -107,7 +111,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman return cmd } -func viewRun(opts *ViewOptions) error { +func viewRun(ctx context.Context, opts *ViewOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -161,9 +165,19 @@ func viewRun(opts *ViewOptions) error { var rs *shared.RulesetREST if opts.Organization != "" { - rs, err = viewOrgRuleset(httpClient, opts.Organization, opts.ID, hostname) + var restClient *githubrest.Client + restClient, err = opts.GitHubREST(hostname) + if err != nil { + return err + } + rs, err = viewOrgRuleset(ctx, restClient, opts.Organization, opts.ID) } else { - rs, err = viewRepoRuleset(httpClient, repoI, opts.ID) + var restClient *githubrest.Client + restClient, err = opts.GitHubREST(repoI.RepoHost()) + if err != nil { + return err + } + rs, err = viewRepoRuleset(ctx, restClient, repoI, opts.ID) } if err != nil { diff --git a/pkg/cmd/ruleset/view/view_test.go b/pkg/cmd/ruleset/view/view_test.go index 9aa4b1c00f7..783478faddf 100644 --- a/pkg/cmd/ruleset/view/view_test.go +++ b/pkg/cmd/ruleset/view/view_test.go @@ -2,6 +2,7 @@ package view import ( "bytes" + "context" "io" "net/http" "testing" @@ -381,6 +382,7 @@ func Test_viewRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) // only set this if org is not set, because the repo isn't needed if --org is provided and // leaving it undefined will catch potential errors @@ -393,7 +395,7 @@ func Test_viewRun(t *testing.T) { browser := &browser.Stub{} tt.opts.Browser = browser - err := viewRun(&tt.opts) + err := viewRun(context.Background(), &tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) diff --git a/pkg/cmd/run/cancel/cancel.go b/pkg/cmd/run/cancel/cancel.go index 168921bf663..baf56fc9dea 100644 --- a/pkg/cmd/run/cancel/cancel.go +++ b/pkg/cmd/run/cancel/cancel.go @@ -1,12 +1,12 @@ package cancel import ( + "context" "errors" "fmt" "net/http" "strconv" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" @@ -17,7 +17,7 @@ import ( ) type CancelOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Prompter shared.Prompter @@ -31,7 +31,7 @@ type CancelOptions struct { func NewCmdCancel(f *cmdutil.Factory, runF func(*CancelOptions) error) *cobra.Command { opts := &CancelOptions{ IO: f.IOStreams, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Prompter: f.Prompter, } @@ -55,7 +55,7 @@ func NewCmdCancel(f *cmdutil.Factory, runF func(*CancelOptions) error) *cobra.Co return runF(opts) } - return runCancel(opts) + return runCancel(cmd.Context(), opts) }, } @@ -64,18 +64,13 @@ func NewCmdCancel(f *cmdutil.Factory, runF func(*CancelOptions) error) *cobra.Co return cmd } -func runCancel(opts *CancelOptions) error { +func runCancel(ctx context.Context, opts *CancelOptions) error { if opts.RunID != "" { _, err := strconv.Atoi(opts.RunID) if err != nil { return fmt.Errorf("invalid run-id %#v", opts.RunID) } } - 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() @@ -84,11 +79,16 @@ func runCancel(opts *CancelOptions) error { return fmt.Errorf("failed to determine base repo: %w", err) } + client, err := opts.GitHubREST(repo.RepoHost()) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + runID := opts.RunID var run *shared.Run if opts.Prompt { - runs, err := shared.GetRunsWithFilter(client, repo, nil, 10, func(run shared.Run) bool { + runs, err := shared.GetRunsWithFilter(ctx, client, repo, nil, 10, func(run shared.Run) bool { return run.Status != shared.Completed }) if err != nil { @@ -108,7 +108,7 @@ func runCancel(opts *CancelOptions) error { } } } else { - run, err = shared.GetRun(client, repo, runID, 0) + run, err = shared.GetRun(ctx, client, repo, runID, 0) if err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { @@ -122,7 +122,7 @@ func runCancel(opts *CancelOptions) error { force := opts.Force - err = cancelWorkflowRun(client, repo, fmt.Sprintf("%d", run.ID), force) + err = cancelWorkflowRun(ctx, client, repo, fmt.Sprintf("%d", run.ID), force) if err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { @@ -143,7 +143,7 @@ func runCancel(opts *CancelOptions) error { return nil } -func cancelWorkflowRun(client *api.Client, repo ghrepo.Interface, runID string, force bool) error { +func cancelWorkflowRun(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, runID string, force bool) error { var path *safeurl.MutableSafeURL var err error if force { @@ -155,10 +155,13 @@ func cancelWorkflowRun(client *api.Client, repo ghrepo.Interface, runID string, return err } - err = client.REST(repo.RepoHost(), "POST", path.String(), nil, nil) + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), nil) if err != nil { return err } + if _, err := client.Do(req, nil); err != nil { + return err + } return nil } diff --git a/pkg/cmd/run/cancel/cancel_test.go b/pkg/cmd/run/cancel/cancel_test.go index 44ed8db8084..37223fd3890 100644 --- a/pkg/cmd/run/cancel/cancel_test.go +++ b/pkg/cmd/run/cancel/cancel_test.go @@ -3,7 +3,6 @@ package cancel import ( "bytes" "io" - "net/http" "testing" "github.com/cli/cli/v2/internal/ghrepo" @@ -269,9 +268,7 @@ func TestRunCancel(t *testing.T) { for _, tt := range tests { reg := &httpmock.Registry{} tt.httpStubs(reg) - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, _ := iostreams.Test() ios.SetStdoutTTY(true) @@ -288,7 +285,7 @@ func TestRunCancel(t *testing.T) { } t.Run(tt.name, func(t *testing.T) { - err := runCancel(tt.opts) + err := runCancel(t.Context(), tt.opts) if tt.wantErr { assert.Error(t, err) if tt.errMsg != "" { diff --git a/pkg/cmd/run/delete/delete.go b/pkg/cmd/run/delete/delete.go index 5a2bfaf6988..0d437d8cbc2 100644 --- a/pkg/cmd/run/delete/delete.go +++ b/pkg/cmd/run/delete/delete.go @@ -1,12 +1,12 @@ package delete import ( + "context" "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/githubrest" "github.com/cli/cli/v2/internal/prompter" @@ -22,7 +22,7 @@ const ( ) type DeleteOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Prompter prompter.Prompter @@ -33,7 +33,7 @@ type DeleteOptions struct { func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { opts := &DeleteOptions{ IO: f.IOStreams, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Prompter: f.Prompter, } @@ -64,20 +64,14 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return runF(opts) } - return runDelete(opts) + return runDelete(cmd.Context(), 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) - +func runDelete(ctx context.Context, opts *DeleteOptions) error { cs := opts.IO.ColorScheme() repo, err := opts.BaseRepo() @@ -85,11 +79,16 @@ func runDelete(opts *DeleteOptions) error { return fmt.Errorf("failed to determine base repo: %w", err) } + client, err := opts.GitHubREST(repo.RepoHost()) + if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } + runID := opts.RunID var run *shared.Run if opts.Prompt { - payload, err := shared.GetRuns(client, repo, nil, defaultRunGetLimit) + payload, err := shared.GetRuns(ctx, client, repo, nil, defaultRunGetLimit) if err != nil { return fmt.Errorf("failed to get runs: %w", err) } @@ -111,7 +110,7 @@ func runDelete(opts *DeleteOptions) error { } } } else { - run, err = shared.GetRun(client, repo, runID, 0) + run, err = shared.GetRun(ctx, client, repo, runID, 0) if err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { @@ -123,7 +122,7 @@ func runDelete(opts *DeleteOptions) error { } } - err = deleteWorkflowRun(client, repo, fmt.Sprintf("%d", run.ID)) + err = deleteWorkflowRun(ctx, client, repo, fmt.Sprintf("%d", run.ID)) if err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { @@ -139,14 +138,17 @@ func runDelete(opts *DeleteOptions) error { return nil } -func deleteWorkflowRun(client *api.Client, repo ghrepo.Interface, runID string) error { +func deleteWorkflowRun(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, runID string) error { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs", runID) if err != nil { return err } - err = client.REST(repo.RepoHost(), "DELETE", path.String(), nil, nil) + req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), nil) if err != nil { return err } + if _, err := client.Do(req, nil); err != nil { + return err + } return nil } diff --git a/pkg/cmd/run/delete/delete_test.go b/pkg/cmd/run/delete/delete_test.go index b9ed2f6a09c..bb287bfb3a0 100644 --- a/pkg/cmd/run/delete/delete_test.go +++ b/pkg/cmd/run/delete/delete_test.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "io" - "net/http" "testing" "github.com/cli/cli/v2/internal/ghrepo" @@ -165,9 +164,7 @@ func TestRunDelete(t *testing.T) { // mock http reg := &httpmock.Registry{} tt.httpStubs(reg) - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) // mock IO ios, _, stdout, _ := iostreams.Test() @@ -184,7 +181,7 @@ func TestRunDelete(t *testing.T) { // execute test t.Run(tt.name, func(t *testing.T) { - err := runDelete(tt.opts) + err := runDelete(t.Context(), tt.opts) if tt.wantErr { assert.Error(t, err) if tt.errMsg != "" { diff --git a/pkg/cmd/run/list/list.go b/pkg/cmd/run/list/list.go index e6475d34a64..68778ad7660 100644 --- a/pkg/cmd/run/list/list.go +++ b/pkg/cmd/run/list/list.go @@ -1,14 +1,14 @@ package list import ( + "context" "fmt" - "net/http" "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/run/shared" workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared" @@ -23,7 +23,7 @@ const ( type ListOptions struct { IO *iostreams.IOStreams - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) BaseRepo func() (ghrepo.Interface, error) Prompter iprompter @@ -49,7 +49,7 @@ type iprompter interface { func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ IO: f.IOStreams, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Prompter: f.Prompter, now: time.Now(), } @@ -81,7 +81,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return runF(opts) } - return listRun(opts) + return listRun(cmd.Context(), opts) }, } @@ -101,17 +101,16 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return cmd } -func listRun(opts *ListOptions) error { +func listRun(ctx context.Context, opts *ListOptions) error { baseRepo, err := opts.BaseRepo() if err != nil { return fmt.Errorf("failed to determine base repo: %w", err) } - c, err := opts.HttpClient() + client, err := opts.GitHubREST(baseRepo.RepoHost()) if err != nil { - return fmt.Errorf("failed to create http client: %w", err) + return fmt.Errorf("failed to create client: %w", err) } - client := api.NewClientFromHTTP(c) filters := &shared.FilterOptions{ Branch: opts.Branch, @@ -133,14 +132,14 @@ func listRun(opts *ListOptions) error { // note: this will be incomplete if more workflow states are added to `workflowShared` states = append(states, workflowShared.DisabledManually, workflowShared.DisabledInactivity) } - if workflow, err := workflowShared.ResolveWorkflow(opts.Prompter, opts.IO, client, baseRepo, false, opts.WorkflowSelector, states); err == nil { + if workflow, err := workflowShared.ResolveWorkflow(ctx, opts.Prompter, opts.IO, client, baseRepo, false, opts.WorkflowSelector, states); err == nil { filters.WorkflowID = workflow.ID filters.WorkflowName = workflow.Name } else { return err } } - runsResult, err := shared.GetRuns(client, baseRepo, filters, opts.Limit) + runsResult, err := shared.GetRuns(ctx, client, baseRepo, filters, opts.Limit) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get runs: %w", err) diff --git a/pkg/cmd/run/list/list_test.go b/pkg/cmd/run/list/list_test.go index 0f11e492a79..1e1cb9d637a 100644 --- a/pkg/cmd/run/list/list_test.go +++ b/pkg/cmd/run/list/list_test.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "io" - "net/http" "net/url" "testing" "time" @@ -694,9 +693,7 @@ func TestListRun(t *testing.T) { defer reg.Verify(t) tt.stubs(reg) - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.isTTY) @@ -705,7 +702,7 @@ func TestListRun(t *testing.T) { return ghrepo.FromFullName("OWNER/REPO") } - err := listRun(tt.opts) + err := listRun(t.Context(), tt.opts) if tt.wantErr { assert.Error(t, err) assert.Equal(t, tt.wantErrMsg, err.Error()) diff --git a/pkg/cmd/run/rerun/rerun.go b/pkg/cmd/run/rerun/rerun.go index 93f6262fae7..41ae500ad97 100644 --- a/pkg/cmd/run/rerun/rerun.go +++ b/pkg/cmd/run/rerun/rerun.go @@ -2,6 +2,7 @@ package rerun import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -10,7 +11,6 @@ import ( "strconv" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" @@ -21,7 +21,7 @@ import ( ) type RerunOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Prompter shared.Prompter @@ -38,7 +38,7 @@ func NewCmdRerun(f *cmdutil.Factory, runF func(*RerunOptions) error) *cobra.Comm opts := &RerunOptions{ IO: f.IOStreams, Prompter: f.Prompter, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -84,7 +84,7 @@ func NewCmdRerun(f *cmdutil.Factory, runF func(*RerunOptions) error) *cobra.Comm if runF != nil { return runF(opts) } - return runRerun(opts) + return runRerun(cmd.Context(), opts) }, } @@ -95,16 +95,15 @@ func NewCmdRerun(f *cmdutil.Factory, runF func(*RerunOptions) error) *cobra.Comm return cmd } -func runRerun(opts *RerunOptions) error { - c, err := opts.HttpClient() +func runRerun(ctx context.Context, opts *RerunOptions) error { + repo, err := opts.BaseRepo() if err != nil { - return fmt.Errorf("failed to create http client: %w", err) + return fmt.Errorf("failed to determine base repo: %w", err) } - client := api.NewClientFromHTTP(c) - repo, err := opts.BaseRepo() + client, err := opts.GitHubREST(repo.RepoHost()) if err != nil { - return fmt.Errorf("failed to determine base repo: %w", err) + return fmt.Errorf("failed to create client: %w", err) } cs := opts.IO.ColorScheme() @@ -115,7 +114,7 @@ func runRerun(opts *RerunOptions) error { if jobID != "" { opts.IO.StartProgressIndicator() - selectedJob, err = shared.GetJob(client, repo, jobID) + selectedJob, err = shared.GetJob(ctx, client, repo, jobID) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get job: %w", err) @@ -124,7 +123,7 @@ func runRerun(opts *RerunOptions) error { } if opts.Prompt { - runs, err := shared.GetRunsWithFilter(client, repo, nil, 10, func(run shared.Run) bool { + runs, err := shared.GetRunsWithFilter(ctx, client, repo, nil, 10, func(run shared.Run) bool { if run.Status != shared.Completed { return false } @@ -149,7 +148,7 @@ func runRerun(opts *RerunOptions) error { } if opts.JobID != "" { - err = rerunJob(client, repo, selectedJob, opts.Debug) + err = rerunJob(ctx, client, repo, selectedJob, opts.Debug) if err != nil { return err } @@ -162,13 +161,13 @@ func runRerun(opts *RerunOptions) error { } } else { opts.IO.StartProgressIndicator() - run, err := shared.GetRun(client, repo, runID, 0) + run, err := shared.GetRun(ctx, client, repo, runID, 0) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get run: %w", err) } - err = rerunRun(client, repo, run, opts.OnlyFailed, opts.Debug) + err = rerunRun(ctx, client, repo, run, opts.OnlyFailed, opts.Debug) if err != nil { return err } @@ -188,7 +187,7 @@ func runRerun(opts *RerunOptions) error { return nil } -func rerunRun(client *api.Client, repo ghrepo.Interface, run *shared.Run, onlyFailed, debug bool) error { +func rerunRun(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, run *shared.Run, onlyFailed, debug bool) error { runVerb := "rerun" if onlyFailed { runVerb = "rerun-failed-jobs" @@ -204,8 +203,11 @@ func rerunRun(client *api.Client, repo ghrepo.Interface, run *shared.Run, onlyFa return err } - err = client.REST(repo.RepoHost(), "POST", path.String(), body, nil) + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), body) if err != nil { + return err + } + if _, err := client.Do(req, nil); err != nil { var httpError *githubrest.ErrorResponse if errors.As(err, &httpError) && httpError.StatusCode == 403 { return fmt.Errorf("run %d cannot be rerun; %s", run.ID, httpError.Message) @@ -215,7 +217,7 @@ func rerunRun(client *api.Client, repo ghrepo.Interface, run *shared.Run, onlyFa return nil } -func rerunJob(client *api.Client, repo ghrepo.Interface, job *shared.Job, debug bool) error { +func rerunJob(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, job *shared.Job, debug bool) error { body, err := requestBody(debug) if err != nil { return fmt.Errorf("failed to create rerun body: %w", err) @@ -226,8 +228,11 @@ func rerunJob(client *api.Client, repo ghrepo.Interface, job *shared.Job, debug return err } - err = client.REST(repo.RepoHost(), "POST", path.String(), body, nil) + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), body) if err != nil { + return err + } + if _, err := client.Do(req, nil); err != nil { var httpError *githubrest.ErrorResponse if errors.As(err, &httpError) && httpError.StatusCode == 403 { return fmt.Errorf("job %d cannot be rerun", job.ID) diff --git a/pkg/cmd/run/rerun/rerun_test.go b/pkg/cmd/run/rerun/rerun_test.go index 77f0a922fd3..126073273c6 100644 --- a/pkg/cmd/run/rerun/rerun_test.go +++ b/pkg/cmd/run/rerun/rerun_test.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "io" - "net/http" "testing" "github.com/cli/cli/v2/internal/ghrepo" @@ -435,9 +434,7 @@ func TestRerun(t *testing.T) { for _, tt := range tests { reg := &httpmock.Registry{} tt.httpStubs(reg) - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, _ := iostreams.Test() ios.SetStdinTTY(tt.tty) @@ -454,7 +451,7 @@ func TestRerun(t *testing.T) { } t.Run(tt.name, func(t *testing.T) { - err := runRerun(tt.opts) + err := runRerun(t.Context(), tt.opts) if tt.wantErr { assert.Error(t, err) assert.Equal(t, tt.errOut, err.Error()) diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index c7b0891945e..e89348dba5c 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -1,6 +1,7 @@ package shared import ( + "context" "errors" "fmt" "net/http" @@ -280,7 +281,7 @@ var ErrMissingAnnotationsPermissions = errors.New("missing annotations permissio // If the API returns a 403, a custom ErrMissingAnnotationsPermissions error is returned. // // When fine-grained PATs support checks:read permission, we can remove the need for this at the call sites. -func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annotation, error) { +func GetAnnotations(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, job Job) ([]Annotation, error) { var result []*Annotation path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "check-runs", strconv.FormatInt(job.ID, 10), "annotations") @@ -288,8 +289,11 @@ func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annot return nil, err } - err = client.REST(repo.RepoHost(), "GET", path.String(), nil, &result) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) if err != nil { + return nil, err + } + if _, err := client.Do(req, &result); err != nil { var httpError *githubrest.ErrorResponse if !errors.As(err, &httpError) { return nil, err @@ -347,8 +351,8 @@ type FilterOptions struct { } // GetRunsWithFilter fetches 50 runs from the API and filters them in-memory -func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int, f func(Run) bool) ([]Run, error) { - runs, err := GetRuns(client, repo, opts, 50) +func GetRunsWithFilter(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, opts *FilterOptions, limit int, f func(Run) bool) ([]Run, error) { + runs, err := GetRuns(ctx, client, repo, opts, 50) if err != nil { return nil, err } @@ -366,7 +370,7 @@ func GetRunsWithFilter(client *api.Client, repo ghrepo.Interface, opts *FilterOp return filtered, nil } -func GetRuns(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, limit int) (*RunsPayload, error) { +func GetRuns(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, opts *FilterOptions, limit int) (*RunsPayload, error) { u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs") if err != nil { return nil, err @@ -412,11 +416,15 @@ func GetRuns(client *api.Client, repo ghrepo.Interface, opts *FilterOptions, lim pagination: for pageURL.String() != "" { var response RunsPayload - next, err := client.RESTWithNext(repo.RepoHost(), "GET", pageURL.String(), nil, &response) + req, err := client.NewRequest(ctx, http.MethodGet, pageURL.String(), nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req, &response) if err != nil { return nil, err } - pageURL = safeurl.NewImmutableSafeURL(next) + pageURL = safeurl.NewImmutableSafeURL(resp.NextPage()) if result == nil { result = &response @@ -438,7 +446,7 @@ pagination: result.WorkflowRuns[i].workflowName = opts.WorkflowName } } else if len(result.WorkflowRuns) > 0 { - if err := preloadWorkflowNames(client, repo, result.WorkflowRuns); err != nil { + if err := preloadWorkflowNames(ctx, client, repo, result.WorkflowRuns); err != nil { return result, err } } @@ -446,8 +454,8 @@ pagination: return result, nil } -func preloadWorkflowNames(client *api.Client, repo ghrepo.Interface, runs []Run) error { - workflows, err := workflowShared.GetWorkflows(client, repo, 0) +func preloadWorkflowNames(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, runs []Run) error { + workflows, err := workflowShared.GetWorkflows(ctx, client, repo, 0) if err != nil { return err } @@ -460,7 +468,7 @@ func preloadWorkflowNames(client *api.Client, repo ghrepo.Interface, runs []Run) for i, run := range runs { if _, ok := workflowMap[run.WorkflowID]; !ok { // Look up workflow by ID because it may have been deleted - workflow, err := workflowShared.GetWorkflow(client, repo, run.WorkflowID) + workflow, err := workflowShared.GetWorkflow(ctx, client, repo, run.WorkflowID) // If the error is an httpError and it is a 404, this is likely a // organization or enterprise ruleset workflow. The user does not // have permissions to view the details of the workflow, so we cannot @@ -488,7 +496,7 @@ type JobsPayload struct { Jobs []Job } -func GetJobs(client *api.Client, repo ghrepo.Interface, runID int64, jobsURL safeurl.SafeURL, attempt uint64) ([]Job, error) { +func GetJobs(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, runID int64, jobsURL safeurl.SafeURL, attempt uint64) ([]Job, error) { var jobsPath safeurl.SafeURL if attempt > 0 { p, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs", strconv.FormatInt(runID, 10), "attempts", strconv.FormatUint(attempt, 10), "jobs") @@ -513,27 +521,34 @@ func GetJobs(client *api.Client, repo ghrepo.Interface, runID int64, jobsURL saf jobs := []Job{} for jobsPath.String() != "" { var resp JobsPayload - next, err := client.RESTWithNext(repo.RepoHost(), http.MethodGet, jobsPath.String(), nil, &resp) + req, err := client.NewRequest(ctx, http.MethodGet, jobsPath.String(), nil) + if err != nil { + return nil, err + } + response, err := client.Do(req, &resp) if err != nil { return nil, err } jobs = append(jobs, resp.Jobs...) - jobsPath = safeurl.NewImmutableSafeURL(next) + jobsPath = safeurl.NewImmutableSafeURL(response.NextPage()) } return jobs, nil } -func GetJob(client *api.Client, repo ghrepo.Interface, jobID string) (*Job, error) { +func GetJob(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, jobID string) (*Job, error) { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "jobs", jobID) if err != nil { return nil, err } var result Job - err = client.REST(repo.RepoHost(), "GET", path.String(), nil, &result) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) if err != nil { return nil, err } + if _, err := client.Do(req, &result); err != nil { + return nil, err + } return &result, nil } @@ -560,7 +575,7 @@ func SelectRun(p Prompter, cs *iostreams.ColorScheme, runs []Run) (string, error return fmt.Sprintf("%d", runs[selected].ID), nil } -func GetRun(client *api.Client, repo ghrepo.Interface, runID string, attempt uint64) (*Run, error) { +func GetRun(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, runID string, attempt uint64) (*Run, error) { var result Run u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "runs", runID) @@ -576,10 +591,13 @@ func GetRun(client *api.Client, repo ghrepo.Interface, runID string, attempt uin } u.SetQuery("exclude_pull_requests", "true") - err = client.REST(repo.RepoHost(), "GET", u.String(), nil, &result) + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } + if _, err := client.Do(req, &result); err != nil { + return nil, err + } if attempt > 0 { result.URL, err = url.JoinPath(result.URL, fmt.Sprintf("/attempts/%d", attempt)) @@ -589,7 +607,7 @@ func GetRun(client *api.Client, repo ghrepo.Interface, runID string, attempt uin } // Set name to workflow name - workflow, err := workflowShared.GetWorkflow(client, repo, result.WorkflowID) + workflow, err := workflowShared.GetWorkflow(ctx, client, repo, result.WorkflowID) if err != nil { return nil, err } else { diff --git a/pkg/cmd/run/shared/shared_test.go b/pkg/cmd/run/shared/shared_test.go index 752ba2fc457..adf84849a81 100644 --- a/pkg/cmd/run/shared/shared_test.go +++ b/pkg/cmd/run/shared/shared_test.go @@ -3,13 +3,11 @@ package shared import ( "bytes" "encoding/json" - "net/http" "strings" "testing" "time" "github.com/MakeNowJust/heredoc" - "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" @@ -49,11 +47,11 @@ func TestGetAnnotations404(t *testing.T) { httpmock.REST("GET", "repos/OWNER/REPO/check-runs/123456/annotations"), httpmock.StatusStringResponse(404, "not found")) - httpClient := &http.Client{Transport: reg} - apiClient := api.NewClientFromHTTP(httpClient) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) repo := ghrepo.New("OWNER", "REPO") - result, err := GetAnnotations(apiClient, repo, Job{ID: 123456, Name: "a job"}) + result, err := GetAnnotations(t.Context(), client, repo, Job{ID: 123456, Name: "a job"}) assert.NoError(t, err) assert.Equal(t, result, []Annotation{}) } diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index a4043e06c88..29d3eef24a3 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -236,7 +236,7 @@ func runView(ctx context.Context, opts *ViewOptions) error { if jobID != "" { opts.IO.StartProgressIndicator() - selectedJob, err = shared.GetJob(client, repo, jobID) + selectedJob, err = shared.GetJob(ctx, restClient, repo, jobID) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get job: %w", err) @@ -250,7 +250,7 @@ func runView(ctx context.Context, opts *ViewOptions) error { if opts.Prompt { // TODO arbitrary limit opts.IO.StartProgressIndicator() - runs, err := shared.GetRuns(client, repo, nil, 10) + runs, err := shared.GetRuns(ctx, restClient, repo, nil, 10) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get runs: %w", err) @@ -262,7 +262,7 @@ func runView(ctx context.Context, opts *ViewOptions) error { } opts.IO.StartProgressIndicator() - run, err = shared.GetRun(client, repo, runID, attempt) + run, err = shared.GetRun(ctx, restClient, repo, runID, attempt) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get run: %w", err) @@ -270,7 +270,7 @@ func runView(ctx context.Context, opts *ViewOptions) error { if shouldFetchJobs(opts) { opts.IO.StartProgressIndicator() - jobs, err = shared.GetJobs(client, repo, run.ID, safeurl.NewImmutableSafeURL(run.JobsURL), attempt) + jobs, err = shared.GetJobs(ctx, restClient, repo, run.ID, safeurl.NewImmutableSafeURL(run.JobsURL), attempt) opts.IO.StopProgressIndicator() if err != nil { return err @@ -309,7 +309,7 @@ func runView(ctx context.Context, opts *ViewOptions) error { if selectedJob == nil && len(jobs) == 0 { opts.IO.StartProgressIndicator() - jobs, err = shared.GetJobs(client, repo, run.ID, safeurl.NewImmutableSafeURL(run.JobsURL), attempt) + jobs, err = shared.GetJobs(ctx, restClient, repo, run.ID, safeurl.NewImmutableSafeURL(run.JobsURL), attempt) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get jobs: %w", err) @@ -373,7 +373,7 @@ func runView(ctx context.Context, opts *ViewOptions) error { var missingAnnotationsPermissions bool for _, job := range jobs { - as, err := shared.GetAnnotations(client, repo, job) + as, err := shared.GetAnnotations(ctx, restClient, repo, job) if err != nil { if err != shared.ErrMissingAnnotationsPermissions { return fmt.Errorf("failed to get annotations: %w", err) diff --git a/pkg/cmd/run/watch/watch.go b/pkg/cmd/run/watch/watch.go index 53ad4bc3540..dbdc1626efa 100644 --- a/pkg/cmd/run/watch/watch.go +++ b/pkg/cmd/run/watch/watch.go @@ -2,6 +2,7 @@ package watch import ( "bytes" + "context" "fmt" "io" "net/http" @@ -10,6 +11,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/run/shared" @@ -23,6 +25,7 @@ const defaultInterval int = 3 type WatchOptions struct { IO *iostreams.IOStreams HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) BaseRepo func() (ghrepo.Interface, error) Prompter shared.Prompter @@ -40,6 +43,7 @@ func NewCmdWatch(f *cmdutil.Factory, runF func(*WatchOptions) error) *cobra.Comm opts := &WatchOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Prompter: f.Prompter, Now: time.Now, } @@ -82,7 +86,7 @@ func NewCmdWatch(f *cmdutil.Factory, runF func(*WatchOptions) error) *cobra.Comm return runF(opts) } - return watchRun(opts) + return watchRun(cmd.Context(), opts) }, } cmd.Flags().BoolVar(&opts.ExitStatus, "exit-status", false, "Exit with non-zero status if run fails") @@ -92,7 +96,7 @@ func NewCmdWatch(f *cmdutil.Factory, runF func(*WatchOptions) error) *cobra.Comm return cmd } -func watchRun(opts *WatchOptions) error { +func watchRun(ctx context.Context, opts *WatchOptions) error { c, err := opts.HttpClient() if err != nil { return fmt.Errorf("failed to create http client: %w", err) @@ -104,13 +108,18 @@ func watchRun(opts *WatchOptions) error { return fmt.Errorf("failed to determine base repo: %w", err) } + restClient, err := opts.GitHubREST(repo.RepoHost()) + if err != nil { + return fmt.Errorf("failed to create REST client: %w", err) + } + cs := opts.IO.ColorScheme() runID := opts.RunID var run *shared.Run if opts.Prompt { - runs, err := shared.GetRunsWithFilter(client, repo, nil, 10, func(run shared.Run) bool { + runs, err := shared.GetRunsWithFilter(ctx, restClient, repo, nil, 10, func(run shared.Run) bool { return run.Status != shared.Completed }) if err != nil { @@ -130,7 +139,7 @@ func watchRun(opts *WatchOptions) error { } } } else { - run, err = shared.GetRun(client, repo, runID, 0) + run, err = shared.GetRun(ctx, restClient, repo, runID, 0) if err != nil { return fmt.Errorf("failed to get run: %w", err) } @@ -161,7 +170,7 @@ func watchRun(opts *WatchOptions) error { opts.IO.StartAlternateScreenBuffer() for run.Status != shared.Completed { // Write to a temporary buffer to reduce total number of fetches - run, err = renderRun(out, *opts, client, repo, run, prNumber, annotationCache) + run, err = renderRun(ctx, out, *opts, restClient, repo, run, prNumber, annotationCache) if err != nil { break } @@ -212,17 +221,17 @@ func watchRun(opts *WatchOptions) error { return nil } -func renderRun(out io.Writer, opts WatchOptions, client *api.Client, repo ghrepo.Interface, run *shared.Run, prNumber string, annotationCache map[int64][]shared.Annotation) (*shared.Run, error) { +func renderRun(ctx context.Context, out io.Writer, opts WatchOptions, client *githubrest.Client, repo ghrepo.Interface, run *shared.Run, prNumber string, annotationCache map[int64][]shared.Annotation) (*shared.Run, error) { cs := opts.IO.ColorScheme() var err error - run, err = shared.GetRun(client, repo, fmt.Sprintf("%d", run.ID), 0) + run, err = shared.GetRun(ctx, client, repo, fmt.Sprintf("%d", run.ID), 0) if err != nil { return nil, fmt.Errorf("failed to get run: %w", err) } - jobs, err := shared.GetJobs(client, repo, run.ID, safeurl.NewImmutableSafeURL(run.JobsURL), 0) + jobs, err := shared.GetJobs(ctx, client, repo, run.ID, safeurl.NewImmutableSafeURL(run.JobsURL), 0) if err != nil { return nil, fmt.Errorf("failed to get jobs: %w", err) } @@ -237,7 +246,7 @@ func renderRun(out io.Writer, opts WatchOptions, client *api.Client, repo ghrepo continue } - as, err := shared.GetAnnotations(client, repo, job) + as, err := shared.GetAnnotations(ctx, client, repo, job) if err != nil { if err != shared.ErrMissingAnnotationsPermissions { return nil, fmt.Errorf("failed to get annotations: %w", err) diff --git a/pkg/cmd/run/watch/watch_test.go b/pkg/cmd/run/watch/watch_test.go index 1471f64cf7e..5bf91f9bd99 100644 --- a/pkg/cmd/run/watch/watch_test.go +++ b/pkg/cmd/run/watch/watch_test.go @@ -381,6 +381,7 @@ func TestWatchRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Now = func() time.Time { notnow, _ := time.Parse("2006-01-02 15:04:05", "2021-02-23 05:50:00") @@ -402,7 +403,7 @@ func TestWatchRun(t *testing.T) { tt.promptStubs(pm) } - err := watchRun(tt.opts) + err := watchRun(t.Context(), tt.opts) if tt.wantErr { assert.EqualError(t, err, tt.errMsg) } else { diff --git a/pkg/cmd/secret/delete/delete.go b/pkg/cmd/secret/delete/delete.go index 2550b8bbe2a..64562b1637b 100644 --- a/pkg/cmd/secret/delete/delete.go +++ b/pkg/cmd/secret/delete/delete.go @@ -1,14 +1,15 @@ package delete import ( + "context" "fmt" "net/http" "os" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/secret/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -17,7 +18,7 @@ import ( ) type DeleteOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Config func() (gh.Config, error) BaseRepo func() (ghrepo.Interface, error) @@ -33,7 +34,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co opts := &DeleteOptions{ IO: f.IOStreams, Config: f.Config, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -73,7 +74,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return runF(opts) } - return removeRun(opts) + return removeRun(cmd.Context(), opts) }, Aliases: []string{ "remove", @@ -87,13 +88,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return cmd } -func removeRun(opts *DeleteOptions) error { - c, err := opts.HttpClient() - if err != nil { - return fmt.Errorf("could not create http client: %w", err) - } - client := api.NewClientFromHTTP(c) - +func removeRun(ctx context.Context, opts *DeleteOptions) error { orgName := opts.OrgName envName := opts.EnvName @@ -144,8 +139,16 @@ func removeRun(opts *DeleteOptions) error { return err } - err = client.REST(host, "DELETE", path.String(), nil, nil) + client, err := opts.GitHubREST(host) + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + + req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), nil) if err != nil { + return err + } + if _, err := client.Do(req, nil); err != nil { return fmt.Errorf("failed to delete secret %s: %w", opts.SecretName, err) } diff --git a/pkg/cmd/secret/delete/delete_test.go b/pkg/cmd/secret/delete/delete_test.go index 570df4615d5..3d16a283c54 100644 --- a/pkg/cmd/secret/delete/delete_test.go +++ b/pkg/cmd/secret/delete/delete_test.go @@ -2,8 +2,8 @@ package delete import ( "bytes" + "context" "io" - "net/http" "testing" ghContext "github.com/cli/cli/v2/context" @@ -359,9 +359,7 @@ func Test_removeRun_repo(t *testing.T) { ios, _, _, _ := iostreams.Test() tt.opts.IO = ios - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -369,7 +367,7 @@ func Test_removeRun_repo(t *testing.T) { return ghrepo.FromFullNameWithHost("owner/repo", tt.host) } - err := removeRun(tt.opts) + err := removeRun(context.Background(), tt.opts) assert.NoError(t, err) } } @@ -420,14 +418,12 @@ func Test_removeRun_env(t *testing.T) { } return ghrepo.FromFullName("owner/repo") } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } - err := removeRun(tt.opts) + err := removeRun(context.Background(), tt.opts) require.NoError(t, err) } } @@ -487,13 +483,11 @@ func Test_removeRun_org(t *testing.T) { tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.IO = ios tt.opts.SecretName = "tVirus" - err := removeRun(tt.opts) + err := removeRun(context.Background(), tt.opts) assert.NoError(t, err) reg.Verify(t) @@ -511,10 +505,8 @@ func Test_removeRun_user(t *testing.T) { ios, _, _, _ := iostreams.Test() opts := &DeleteOptions{ - IO: ios, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, + IO: ios, + GitHubREST: httpmock.RESTClientFunc(reg), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, @@ -522,7 +514,7 @@ func Test_removeRun_user(t *testing.T) { UserSecrets: true, } - err := removeRun(opts) + err := removeRun(context.Background(), opts) assert.NoError(t, err) reg.Verify(t) diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go index 3f47bc748e1..60d61476397 100644 --- a/pkg/cmd/secret/list/list.go +++ b/pkg/cmd/secret/list/list.go @@ -1,6 +1,7 @@ package list import ( + "context" "fmt" "net/http" "os" @@ -9,9 +10,9 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/tableprinter" @@ -22,7 +23,7 @@ import ( ) type ListOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Config func() (gh.Config, error) BaseRepo func() (ghrepo.Interface, error) @@ -51,7 +52,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman opts := &ListOptions{ IO: f.IOStreams, Config: f.Config, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Now: time.Now, Prompter: f.Prompter, } @@ -92,7 +93,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return runF(opts) } - return listRun(opts) + return listRun(cmd.Context(), opts) }, } @@ -104,17 +105,13 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return cmd } -func listRun(opts *ListOptions) error { - client, err := opts.HttpClient() - if err != nil { - return fmt.Errorf("could not create http client: %w", err) - } - +func listRun(ctx context.Context, opts *ListOptions) error { orgName := opts.OrgName envName := opts.EnvName var baseRepo ghrepo.Interface if orgName == "" && !opts.UserSecrets { + var err error baseRepo, err = opts.BaseRepo() if err != nil { return err @@ -152,9 +149,19 @@ func listRun(opts *ListOptions) error { var secrets []Secret switch secretEntity { case shared.Repository: - secrets, err = getRepoSecrets(client, baseRepo, secretApp) + var client *githubrest.Client + client, err = opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + secrets, err = getRepoSecrets(ctx, client, baseRepo, secretApp) case shared.Environment: - secrets, err = getEnvSecrets(client, baseRepo, envName) + var client *githubrest.Client + client, err = opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + secrets, err = getEnvSecrets(ctx, client, baseRepo, envName) case shared.Organization, shared.User: var cfg gh.Config var host string @@ -166,10 +173,16 @@ func listRun(opts *ListOptions) error { host, _ = cfg.Authentication().DefaultHost() + var client *githubrest.Client + client, err = opts.GitHubREST(host) + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + if secretEntity == shared.User { - secrets, err = getUserSecrets(client, host, showSelectedRepoInfo) + secrets, err = getUserSecrets(ctx, client, showSelectedRepoInfo) } else { - secrets, err = getOrgSecrets(client, host, orgName, showSelectedRepoInfo, secretApp) + secrets, err = getOrgSecrets(ctx, client, orgName, showSelectedRepoInfo, secretApp) } } @@ -248,12 +261,12 @@ func fmtVisibility(s Secret) string { return "" } -func getOrgSecrets(client *http.Client, host, orgName string, showSelectedRepoInfo bool, app shared.App) ([]Secret, error) { +func getOrgSecrets(ctx context.Context, client *githubrest.Client, orgName string, showSelectedRepoInfo bool, app shared.App) ([]Secret, error) { u, err := safeurl.JoinPath("orgs", orgName, string(app), "secrets") if err != nil { return nil, err } - secrets, err := getSecrets(client, host, u) + secrets, err := getSecrets(ctx, client, u) if err != nil { return nil, err } @@ -263,7 +276,7 @@ func getOrgSecrets(client *http.Client, host, orgName string, showSelectedRepoIn if secrets[i].SelectedReposURL == "" { continue } - count, err := selectedRepositoryCount(client, host, safeurl.NewImmutableSafeURL(secrets[i].SelectedReposURL)) + count, err := selectedRepositoryCount(ctx, client, safeurl.NewImmutableSafeURL(secrets[i].SelectedReposURL)) if err != nil { return nil, fmt.Errorf("failed determining selected repositories for %s: %w", secrets[i].Name, err) } @@ -273,12 +286,12 @@ func getOrgSecrets(client *http.Client, host, orgName string, showSelectedRepoIn return secrets, nil } -func getUserSecrets(client *http.Client, host string, showSelectedRepoInfo bool) ([]Secret, error) { +func getUserSecrets(ctx context.Context, client *githubrest.Client, showSelectedRepoInfo bool) ([]Secret, error) { u, err := safeurl.JoinPath("user", "codespaces", "secrets") if err != nil { return nil, err } - secrets, err := getSecrets(client, host, u) + secrets, err := getSecrets(ctx, client, u) if err != nil { return nil, err } @@ -288,7 +301,7 @@ func getUserSecrets(client *http.Client, host string, showSelectedRepoInfo bool) if secrets[i].SelectedReposURL == "" { continue } - count, err := selectedRepositoryCount(client, host, safeurl.NewImmutableSafeURL(secrets[i].SelectedReposURL)) + count, err := selectedRepositoryCount(ctx, client, safeurl.NewImmutableSafeURL(secrets[i].SelectedReposURL)) if err != nil { return nil, fmt.Errorf("failed determining selected repositories for %s: %w", secrets[i].Name, err) } @@ -299,47 +312,53 @@ func getUserSecrets(client *http.Client, host string, showSelectedRepoInfo bool) return secrets, nil } -func getEnvSecrets(client *http.Client, repo ghrepo.Interface, envName string) ([]Secret, error) { +func getEnvSecrets(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, envName string) ([]Secret, error) { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "environments", envName, "secrets") if err != nil { return nil, err } - return getSecrets(client, repo.RepoHost(), path) + return getSecrets(ctx, client, path) } -func getRepoSecrets(client *http.Client, repo ghrepo.Interface, app shared.App) ([]Secret, error) { +func getRepoSecrets(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, app shared.App) ([]Secret, error) { u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), string(app), "secrets") if err != nil { return nil, err } - return getSecrets(client, repo.RepoHost(), u) + return getSecrets(ctx, client, u) } -func getSecrets(client *http.Client, host string, u *safeurl.MutableSafeURL) ([]Secret, error) { +func getSecrets(ctx context.Context, client *githubrest.Client, u *safeurl.MutableSafeURL) ([]Secret, error) { var results []Secret - apiClient := api.NewClientFromHTTP(client) u.SetQuery("per_page", "100") var pageURL safeurl.SafeURL = u for pageURL.String() != "" { response := struct { Secrets []Secret }{} - next, err := apiClient.RESTWithNext(host, "GET", pageURL.String(), nil, &response) + req, err := client.NewRequest(ctx, http.MethodGet, pageURL.String(), nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req, &response) if err != nil { return nil, err } - pageURL = safeurl.NewImmutableSafeURL(next) + pageURL = safeurl.NewImmutableSafeURL(resp.NextPage()) results = append(results, response.Secrets...) } return results, nil } -func selectedRepositoryCount(client *http.Client, host string, selectedReposURL safeurl.SafeURL) (int, error) { - apiClient := api.NewClientFromHTTP(client) +func selectedRepositoryCount(ctx context.Context, client *githubrest.Client, selectedReposURL safeurl.SafeURL) (int, error) { response := struct { TotalCount int `json:"total_count"` }{} - if err := apiClient.REST(host, "GET", selectedReposURL.String(), nil, &response); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, selectedReposURL.String(), nil) + if err != nil { + return 0, err + } + if _, err := client.Do(req, &response); err != nil { return 0, err } return response.TotalCount, nil diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go index 7e6c88a0002..8881a971799 100644 --- a/pkg/cmd/secret/list/list_test.go +++ b/pkg/cmd/secret/list/list_test.go @@ -2,9 +2,9 @@ package list import ( "bytes" + "context" "fmt" "io" - "net/http" "net/url" "strings" "testing" @@ -624,9 +624,7 @@ func Test_listRun(t *testing.T) { tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -641,7 +639,7 @@ func Test_listRun(t *testing.T) { tt.opts.Exporter = exporter } - err := listRun(tt.opts) + err := listRun(context.Background(), tt.opts) assert.NoError(t, err) expected := fmt.Sprintf("%s\n", strings.Join(tt.wantOut, "\n")) @@ -817,9 +815,7 @@ func Test_listRun_populatesNumSelectedReposIfRequired(t *testing.T) { opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") } - opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + opts.GitHubREST = httpmock.RESTClientFunc(reg) opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -828,7 +824,7 @@ func Test_listRun_populatesNumSelectedReposIfRequired(t *testing.T) { return t } - err := listRun(opts) + err := listRun(context.Background(), opts) assert.NoError(t, err) if tt.wantPopulated { @@ -851,16 +847,17 @@ func Test_getSecrets_pagination(t *testing.T) { httpmock.WithHeader( httpmock.StringResponse(`{"secrets":[{},{}]}`), "Link", - `; rel="previous", ; rel="next"`), + `; rel="previous", ; rel="next"`), ) reg.Register( httpmock.REST("GET", "page/2"), httpmock.StringResponse(`{"secrets":[{},{}]}`), ) - client := &http.Client{Transport: reg} + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) u, err := safeurl.JoinPath("path", "to") require.NoError(t, err) - secrets, err := getSecrets(client, "github.com", u) + secrets, err := getSecrets(context.Background(), client, u) assert.NoError(t, err) assert.Equal(t, 4, len(secrets)) } diff --git a/pkg/cmd/secret/set/http.go b/pkg/cmd/secret/set/http.go index 43da048a65a..e5c092da556 100644 --- a/pkg/cmd/secret/set/http.go +++ b/pkg/cmd/secret/set/http.go @@ -2,12 +2,14 @@ package set import ( "bytes" + "context" "encoding/json" "fmt" + "net/http" "strconv" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/secret/shared" ) @@ -31,58 +33,64 @@ type PubKey struct { Key string } -func getPubKey(client *api.Client, host string, path safeurl.SafeURL) (*PubKey, error) { - pk := PubKey{} - err := client.REST(host, "GET", path.String(), nil, &pk) +func getPubKey(ctx context.Context, client *githubrest.Client, path safeurl.SafeURL) (*PubKey, error) { + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) if err != nil { return nil, err } + pk := PubKey{} + if _, err := client.Do(req, &pk); err != nil { + return nil, err + } return &pk, nil } -func getOrgPublicKey(client *api.Client, host, orgName string, app shared.App) (*PubKey, error) { +func getOrgPublicKey(ctx context.Context, client *githubrest.Client, orgName string, app shared.App) (*PubKey, error) { u, err := safeurl.JoinPath("orgs", orgName, string(app), "secrets", "public-key") if err != nil { return nil, err } - return getPubKey(client, host, u) + return getPubKey(ctx, client, u) } -func getUserPublicKey(client *api.Client, host string) (*PubKey, error) { +func getUserPublicKey(ctx context.Context, client *githubrest.Client) (*PubKey, error) { u, err := safeurl.JoinPath("user", "codespaces", "secrets", "public-key") if err != nil { return nil, err } - return getPubKey(client, host, u) + return getPubKey(ctx, client, u) } -func getRepoPubKey(client *api.Client, repo ghrepo.Interface, app shared.App) (*PubKey, error) { +func getRepoPubKey(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, app shared.App) (*PubKey, error) { u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), string(app), "secrets", "public-key") if err != nil { return nil, err } - return getPubKey(client, repo.RepoHost(), u) + return getPubKey(ctx, client, u) } -func getEnvPubKey(client *api.Client, repo ghrepo.Interface, envName string) (*PubKey, error) { +func getEnvPubKey(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, envName string) (*PubKey, error) { u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "environments", envName, "secrets", "public-key") if err != nil { return nil, err } - return getPubKey(client, repo.RepoHost(), u) + return getPubKey(ctx, client, u) } -func putSecret(client *api.Client, host string, path safeurl.SafeURL, payload interface{}) error { +func putSecret(ctx context.Context, client *githubrest.Client, path safeurl.SafeURL, payload interface{}) error { payloadBytes, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to serialize: %w", err) } - requestBody := bytes.NewReader(payloadBytes) - - return client.REST(host, "PUT", path.String(), requestBody, nil) + req, err := client.NewRequest(ctx, http.MethodPut, path.String(), bytes.NewReader(payloadBytes)) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err } -func putOrgSecret(client *api.Client, host string, pk *PubKey, orgName, visibility, secretName, eValue string, repositoryIDs []int64, app shared.App) error { +func putOrgSecret(ctx context.Context, client *githubrest.Client, pk *PubKey, orgName, visibility, secretName, eValue string, repositoryIDs []int64, app shared.App) error { path, err := safeurl.JoinPath("orgs", orgName, string(app), "secrets", secretName) if err != nil { return err @@ -101,7 +109,7 @@ func putOrgSecret(client *api.Client, host string, pk *PubKey, orgName, visibili Visibility: visibility, } - return putSecret(client, host, path, payload) + return putSecret(ctx, client, path, payload) } payload := SecretPayload{ @@ -111,10 +119,10 @@ func putOrgSecret(client *api.Client, host string, pk *PubKey, orgName, visibili Visibility: visibility, } - return putSecret(client, host, path, payload) + return putSecret(ctx, client, path, payload) } -func putUserSecret(client *api.Client, host string, pk *PubKey, key, eValue string, repositoryIDs []int64) error { +func putUserSecret(ctx context.Context, client *githubrest.Client, pk *PubKey, key, eValue string, repositoryIDs []int64) error { payload := SecretPayload{ EncryptedValue: eValue, KeyID: pk.ID, @@ -124,10 +132,10 @@ func putUserSecret(client *api.Client, host string, pk *PubKey, key, eValue stri if err != nil { return err } - return putSecret(client, host, path, payload) + return putSecret(ctx, client, path, payload) } -func putEnvSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, envName string, secretName, eValue string) error { +func putEnvSecret(ctx context.Context, client *githubrest.Client, pk *PubKey, repo ghrepo.Interface, envName string, secretName, eValue string) error { payload := SecretPayload{ EncryptedValue: eValue, KeyID: pk.ID, @@ -136,10 +144,10 @@ func putEnvSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, envName if err != nil { return err } - return putSecret(client, repo.RepoHost(), path, payload) + return putSecret(ctx, client, path, payload) } -func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secretName, eValue string, app shared.App) error { +func putRepoSecret(ctx context.Context, client *githubrest.Client, pk *PubKey, repo ghrepo.Interface, secretName, eValue string, app shared.App) error { payload := SecretPayload{ EncryptedValue: eValue, KeyID: pk.ID, @@ -148,5 +156,5 @@ func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secret if err != nil { return err } - return putSecret(client, repo.RepoHost(), path, payload) + return putSecret(ctx, client, path, payload) } diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index 93cc14f219b..0178d6b7d88 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -2,6 +2,7 @@ package set import ( "bytes" + "context" "encoding/base64" "errors" "fmt" @@ -16,6 +17,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/secret/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -26,6 +28,7 @@ import ( type SetOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Config func() (gh.Config, error) BaseRepo func() (ghrepo.Interface, error) @@ -50,6 +53,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Prompter: f.Prompter, } @@ -182,7 +186,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command return runF(opts) } - return setRun(opts) + return setRun(cmd.Context(), opts) }, } @@ -200,7 +204,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command return cmd } -func setRun(opts *SetOptions) error { +func setRun(ctx context.Context, opts *SetOptions) error { orgName := opts.OrgName envName := opts.EnvName @@ -231,7 +235,15 @@ func setRun(opts *SetOptions) error { if err != nil { return fmt.Errorf("could not create http client: %w", err) } - client := api.NewClientFromHTTP(c) + // The GraphQL client is retained because mapping repository names to IDs is + // a GraphQL query; the REST client below handles the public key and secret + // requests. + gqlClient := api.NewClientFromHTTP(c) + + restClient, err := opts.GitHubREST(host) + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } secretEntity, err := shared.GetSecretEntity(orgName, envName, opts.UserSecrets) if err != nil { @@ -250,13 +262,13 @@ func setRun(opts *SetOptions) error { var pk *PubKey switch secretEntity { case shared.Organization: - pk, err = getOrgPublicKey(client, host, orgName, secretApp) + pk, err = getOrgPublicKey(ctx, restClient, orgName, secretApp) case shared.Environment: - pk, err = getEnvPubKey(client, baseRepo, envName) + pk, err = getEnvPubKey(ctx, restClient, baseRepo, envName) case shared.User: - pk, err = getUserPublicKey(client, host) + pk, err = getUserPublicKey(ctx, restClient) default: - pk, err = getRepoPubKey(client, baseRepo, secretApp) + pk, err = getRepoPubKey(ctx, restClient, baseRepo, secretApp) } if err != nil { return fmt.Errorf("failed to fetch public key: %w", err) @@ -272,7 +284,7 @@ func setRun(opts *SetOptions) error { repoNamesC <- repoNamesResult{} return } - repositoryIDs, err := mapRepoNamesToIDs(client, host, opts.OrgName, opts.RepositoryNames) + repositoryIDs, err := mapRepoNamesToIDs(gqlClient, host, opts.OrgName, opts.RepositoryNames) repoNamesC <- repoNamesResult{ ids: repositoryIDs, err: err, @@ -291,7 +303,7 @@ func setRun(opts *SetOptions) error { key := secretKey value := secret go func() { - setc <- setSecret(opts, pk, host, client, baseRepo, key, value, repositoryIDs, secretApp, secretEntity) + setc <- setSecret(ctx, opts, pk, restClient, baseRepo, key, value, repositoryIDs, secretApp, secretEntity) }() } @@ -327,7 +339,7 @@ type setResult struct { err error } -func setSecret(opts *SetOptions, pk *PubKey, host string, client *api.Client, baseRepo ghrepo.Interface, secretKey string, secret []byte, repositoryIDs []int64, app shared.App, entity shared.SecretEntity) (res setResult) { +func setSecret(ctx context.Context, opts *SetOptions, pk *PubKey, client *githubrest.Client, baseRepo ghrepo.Interface, secretKey string, secret []byte, repositoryIDs []int64, app shared.App, entity shared.SecretEntity) (res setResult) { orgName := opts.OrgName envName := opts.EnvName res.key = secretKey @@ -358,13 +370,13 @@ func setSecret(opts *SetOptions, pk *PubKey, host string, client *api.Client, ba switch entity { case shared.Organization: - err = putOrgSecret(client, host, pk, orgName, opts.Visibility, secretKey, encoded, repositoryIDs, app) + err = putOrgSecret(ctx, client, pk, orgName, opts.Visibility, secretKey, encoded, repositoryIDs, app) case shared.Environment: - err = putEnvSecret(client, pk, baseRepo, envName, secretKey, encoded) + err = putEnvSecret(ctx, client, pk, baseRepo, envName, secretKey, encoded) case shared.User: - err = putUserSecret(client, host, pk, secretKey, encoded, repositoryIDs) + err = putUserSecret(ctx, client, pk, secretKey, encoded, repositoryIDs) default: - err = putRepoSecret(client, pk, baseRepo, secretKey, encoded, app) + err = putRepoSecret(ctx, client, pk, baseRepo, secretKey, encoded, app) } if err != nil { res.err = fmt.Errorf("failed to set secret %q: %w", secretKey, err) diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go index 237bc70e1dc..5be21cf4db3 100644 --- a/pkg/cmd/secret/set/set_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -2,6 +2,7 @@ package set import ( "bytes" + "context" "encoding/json" "fmt" "io" @@ -469,7 +470,8 @@ func Test_setRun_repo(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, @@ -480,7 +482,7 @@ func Test_setRun_repo(t *testing.T) { Application: tt.opts.Application, } - err := setRun(opts) + err := setRun(context.Background(), opts) assert.NoError(t, err) reg.Verify(t) @@ -510,7 +512,8 @@ func Test_setRun_env(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, @@ -521,7 +524,7 @@ func Test_setRun_env(t *testing.T) { RandomOverride: fakeRandom, } - err := setRun(opts) + err := setRun(context.Background(), opts) assert.NoError(t, err) reg.Verify(t) @@ -663,6 +666,7 @@ func Test_setRun_org(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -671,7 +675,7 @@ func Test_setRun_org(t *testing.T) { tt.opts.Body = "a secret" tt.opts.RandomOverride = fakeRandom - err := setRun(tt.opts) + err := setRun(context.Background(), tt.opts) assert.NoError(t, err) reg.Verify(t) @@ -745,6 +749,7 @@ func Test_setRun_user(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -753,7 +758,7 @@ func Test_setRun_user(t *testing.T) { tt.opts.Body = "a secret" tt.opts.RandomOverride = fakeRandom - err := setRun(tt.opts) + err := setRun(context.Background(), tt.opts) assert.NoError(t, err) reg.Verify(t) @@ -783,6 +788,7 @@ func Test_setRun_shouldNotStore(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, @@ -795,7 +801,7 @@ func Test_setRun_shouldNotStore(t *testing.T) { RandomOverride: fakeRandom, } - err := setRun(opts) + err := setRun(context.Background(), opts) assert.NoError(t, err) assert.Equal(t, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=\n", stdout.String()) diff --git a/pkg/cmd/ssh-key/add/add.go b/pkg/cmd/ssh-key/add/add.go index c620d438bfa..8cffe6fac52 100644 --- a/pkg/cmd/ssh-key/add/add.go +++ b/pkg/cmd/ssh-key/add/add.go @@ -1,12 +1,13 @@ package add import ( + "context" "fmt" "io" - "net/http" "os" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/ssh-key/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -16,7 +17,7 @@ import ( type AddOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) - HTTPClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) KeyFile string Title string @@ -25,7 +26,7 @@ type AddOptions struct { func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command { opts := &AddOptions{ - HTTPClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, IO: f.IOStreams, } @@ -47,7 +48,7 @@ func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command if runF != nil { return runF(opts) } - return runAdd(opts) + return runAdd(cmd.Context(), opts) }, } @@ -57,12 +58,7 @@ func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command return cmd } -func runAdd(opts *AddOptions) error { - httpClient, err := opts.HTTPClient() - if err != nil { - return err - } - +func runAdd(ctx context.Context, opts *AddOptions) error { var keyReader io.Reader if opts.KeyFile == "-" { keyReader = opts.IO.In @@ -83,12 +79,17 @@ func runAdd(opts *AddOptions) error { hostname, _ := cfg.Authentication().DefaultHost() + client, err := opts.GitHubREST(hostname) + if err != nil { + return err + } + var uploaded bool if opts.Type == shared.SigningKey { - uploaded, err = SSHSigningKeyUpload(httpClient, hostname, keyReader, opts.Title) + uploaded, err = SSHSigningKeyUpload(ctx, client, keyReader, opts.Title) } else { - uploaded, err = SSHKeyUpload(httpClient, hostname, keyReader, opts.Title) + uploaded, err = SSHKeyUpload(ctx, client, keyReader, opts.Title) } if err != nil { diff --git a/pkg/cmd/ssh-key/add/add_test.go b/pkg/cmd/ssh-key/add/add_test.go index 7b540f6ca16..e7d3ea00905 100644 --- a/pkg/cmd/ssh-key/add/add_test.go +++ b/pkg/cmd/ssh-key/add/add_test.go @@ -1,6 +1,7 @@ package add import ( + "context" "net/http" "strings" "testing" @@ -129,9 +130,7 @@ func Test_runAdd(t *testing.T) { reg := &httpmock.Registry{} tt.opts.IO = ios - tt.opts.HTTPClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) if tt.httpStubs != nil { tt.httpStubs(reg) } @@ -141,7 +140,7 @@ func Test_runAdd(t *testing.T) { t.Run(tt.name, func(t *testing.T) { defer reg.Verify(t) - err := runAdd(&tt.opts) + err := runAdd(context.Background(), &tt.opts) if tt.wantErrMsg != "" { assert.Equal(t, tt.wantErrMsg, err.Error()) } else { @@ -165,9 +164,11 @@ func TestSSHKeyUploadHTTPError(t *testing.T) { httpmock.StatusStringResponse(http.StatusUnprocessableEntity, `{"message":"Validation Failed"}`), ) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) uploaded, err := SSHKeyUpload( - &http.Client{Transport: reg}, - "github.com", + context.Background(), + client, strings.NewReader("ssh-ed25519 asdf"), "", ) diff --git a/pkg/cmd/ssh-key/add/http.go b/pkg/cmd/ssh-key/add/http.go index 084a77e43cf..8e9ceb187c3 100644 --- a/pkg/cmd/ssh-key/add/http.go +++ b/pkg/cmd/ssh-key/add/http.go @@ -2,19 +2,22 @@ package add import ( "bytes" + "context" "encoding/json" "errors" "io" "net/http" "strings" - "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/ssh-key/shared" ) -// Uploads the provided SSH key. Returns true if the key was uploaded, false if it was not. -func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) (bool, error) { +// SSHKeyUpload uploads the provided SSH key. Returns true if the key was uploaded, false if it was not. +// reqOpts exists for the login flow, which uploads a key with a token that is +// not yet stored in config and so must be supplied per request. +func SSHKeyUpload(ctx context.Context, client *githubrest.Client, keyFile io.Reader, title string, reqOpts ...githubrest.RequestOption) (bool, error) { u, err := safeurl.JoinPath("user", "keys") if err != nil { return false, err @@ -33,7 +36,7 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t keyToCompare := splitKey[0] + " " + splitKey[1] - keys, err := shared.UserKeys(httpClient, hostname, "") + keys, err := shared.UserKeys(ctx, client, "", reqOpts...) if err != nil { return false, err } @@ -49,7 +52,7 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t "key": fullUserKey, } - err = keyUpload(httpClient, hostname, u, payload) + err = keyUpload(ctx, client, u, payload, reqOpts...) if err != nil { return false, err @@ -58,8 +61,8 @@ func SSHKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, t return true, nil } -// Uploads the provided SSH Signing key. Returns true if the key was uploaded, false if it was not. -func SSHSigningKeyUpload(httpClient *http.Client, hostname string, keyFile io.Reader, title string) (bool, error) { +// SSHSigningKeyUpload uploads the provided SSH Signing key. Returns true if the key was uploaded, false if it was not. +func SSHSigningKeyUpload(ctx context.Context, client *githubrest.Client, keyFile io.Reader, title string, reqOpts ...githubrest.RequestOption) (bool, error) { u, err := safeurl.JoinPath("user", "ssh_signing_keys") if err != nil { return false, err @@ -78,7 +81,7 @@ func SSHSigningKeyUpload(httpClient *http.Client, hostname string, keyFile io.Re keyToCompare := splitKey[0] + " " + splitKey[1] - keys, err := shared.UserSigningKeys(httpClient, hostname, "") + keys, err := shared.UserSigningKeys(ctx, client, "", reqOpts...) if err != nil { return false, err } @@ -94,7 +97,7 @@ func SSHSigningKeyUpload(httpClient *http.Client, hostname string, keyFile io.Re "key": fullUserKey, } - err = keyUpload(httpClient, hostname, u, payload) + err = keyUpload(ctx, client, u, payload, reqOpts...) if err != nil { return false, err @@ -103,14 +106,16 @@ func SSHSigningKeyUpload(httpClient *http.Client, hostname string, keyFile io.Re return true, nil } -func keyUpload(httpClient *http.Client, hostname string, u safeurl.SafeURL, payload map[string]string) error { +func keyUpload(ctx context.Context, client *githubrest.Client, u safeurl.SafeURL, payload map[string]string, reqOpts ...githubrest.RequestOption) error { payloadBytes, err := json.Marshal(payload) if err != nil { return err } - // 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(hostname, http.MethodPost, u.String(), bytes.NewBuffer(payloadBytes), nil) + req, err := client.NewRequest(ctx, http.MethodPost, u.String(), bytes.NewBuffer(payloadBytes), reqOpts...) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err } diff --git a/pkg/cmd/ssh-key/delete/delete.go b/pkg/cmd/ssh-key/delete/delete.go index 670b47b3e07..4412e73d395 100644 --- a/pkg/cmd/ssh-key/delete/delete.go +++ b/pkg/cmd/ssh-key/delete/delete.go @@ -1,10 +1,11 @@ package delete import ( + "context" "fmt" - "net/http" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -14,7 +15,7 @@ import ( type DeleteOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) KeyID string Confirmed bool @@ -23,7 +24,7 @@ type DeleteOptions struct { func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { opts := &DeleteOptions{ - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, IO: f.IOStreams, Prompter: f.Prompter, @@ -44,7 +45,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return runF(opts) } - return deleteRun(opts) + return deleteRun(cmd.Context(), opts) }, } @@ -55,19 +56,20 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return cmd } -func deleteRun(opts *DeleteOptions) error { - httpClient, err := opts.HttpClient() +func deleteRun(ctx context.Context, opts *DeleteOptions) error { + cfg, err := opts.Config() if err != nil { return err } - cfg, err := opts.Config() + host, _ := cfg.Authentication().DefaultHost() + + client, err := opts.GitHubREST(host) if err != nil { return err } - host, _ := cfg.Authentication().DefaultHost() - key, err := getSSHKey(httpClient, host, opts.KeyID) + key, err := getSSHKey(ctx, client, opts.KeyID) if err != nil { return err } @@ -78,7 +80,7 @@ func deleteRun(opts *DeleteOptions) error { } } - err = deleteSSHKey(httpClient, host, opts.KeyID) + err = deleteSSHKey(ctx, client, opts.KeyID) if err != nil { return err } diff --git a/pkg/cmd/ssh-key/delete/delete_test.go b/pkg/cmd/ssh-key/delete/delete_test.go index 2b7eabcb44b..a88cec24e34 100644 --- a/pkg/cmd/ssh-key/delete/delete_test.go +++ b/pkg/cmd/ssh-key/delete/delete_test.go @@ -2,6 +2,7 @@ package delete import ( "bytes" + "context" "net/http" "testing" @@ -187,9 +188,7 @@ func Test_deleteRun(t *testing.T) { tt.httpStubs(reg) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -199,7 +198,7 @@ func Test_deleteRun(t *testing.T) { tt.opts.IO = ios t.Run(tt.name, func(t *testing.T) { - err := deleteRun(&tt.opts) + err := deleteRun(context.Background(), &tt.opts) reg.Verify(t) if tt.wantErr { assert.Error(t, err) @@ -220,7 +219,9 @@ func TestDeleteSSHKeyHTTPError(t *testing.T) { httpmock.StatusStringResponse(http.StatusNotFound, `{"message":"Not Found"}`), ) - err := deleteSSHKey(&http.Client{Transport: reg}, "github.com", "1234") + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + err = deleteSSHKey(context.Background(), client, "1234") var httpErr *githubrest.ErrorResponse require.ErrorAs(t, err, &httpErr) @@ -236,7 +237,9 @@ func TestGetSSHKeyHTTPError(t *testing.T) { httpmock.StatusStringResponse(http.StatusNotFound, `{"message":"Not Found"}`), ) - key, err := getSSHKey(&http.Client{Transport: reg}, "github.com", "1234") + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + key, err := getSSHKey(context.Background(), client, "1234") assert.Nil(t, key) var httpErr *githubrest.ErrorResponse diff --git a/pkg/cmd/ssh-key/delete/http.go b/pkg/cmd/ssh-key/delete/http.go index f713d9b4c3e..c7fd8168679 100644 --- a/pkg/cmd/ssh-key/delete/http.go +++ b/pkg/cmd/ssh-key/delete/http.go @@ -1,9 +1,10 @@ package delete import ( + "context" "net/http" - "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -11,30 +12,32 @@ type sshKey struct { Title string } -func deleteSSHKey(httpClient *http.Client, host string, keyID string) error { +func deleteSSHKey(ctx context.Context, client *githubrest.Client, keyID string) error { path, err := safeurl.JoinPath("user", "keys", keyID) if err != nil { return err } - // 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, path.String(), nil, nil) + req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), nil) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err } -func getSSHKey(httpClient *http.Client, host string, keyID string) (*sshKey, error) { +func getSSHKey(ctx context.Context, client *githubrest.Client, keyID string) (*sshKey, error) { var key sshKey path, err := safeurl.JoinPath("user", "keys", keyID) if err != nil { return nil, err } - // 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(host, http.MethodGet, path.String(), nil, &key) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) if err != nil { return nil, err } + if _, err := client.Do(req, &key); err != nil { + return nil, err + } return &key, nil } diff --git a/pkg/cmd/ssh-key/list/list.go b/pkg/cmd/ssh-key/list/list.go index 9d6b474d68d..2db7184044b 100644 --- a/pkg/cmd/ssh-key/list/list.go +++ b/pkg/cmd/ssh-key/list/list.go @@ -1,10 +1,10 @@ package list import ( + "context" "errors" "fmt" "io" - "net/http" "strconv" "time" @@ -20,14 +20,14 @@ import ( type ListOptions struct { IO *iostreams.IOStreams Config func() (gh.Config, error) - HTTPClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ IO: f.IOStreams, Config: f.Config, - HTTPClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -39,31 +39,32 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return listRun(opts) + return listRun(cmd.Context(), opts) }, } return cmd } -func listRun(opts *ListOptions) error { - apiClient, err := opts.HTTPClient() +func listRun(ctx context.Context, opts *ListOptions) error { + cfg, err := opts.Config() if err != nil { return err } - cfg, err := opts.Config() + host, _ := cfg.Authentication().DefaultHost() + + client, err := opts.GitHubREST(host) if err != nil { return err } - host, _ := cfg.Authentication().DefaultHost() - sshAuthKeys, authKeyErr := shared.UserKeys(apiClient, host, "") + sshAuthKeys, authKeyErr := shared.UserKeys(ctx, client, "") if authKeyErr != nil { printError(opts.IO.ErrOut, authKeyErr) } - sshSigningKeys, signKeyErr := shared.UserSigningKeys(apiClient, host, "") + sshSigningKeys, signKeyErr := shared.UserSigningKeys(ctx, client, "") if signKeyErr != nil { printError(opts.IO.ErrOut, signKeyErr) } diff --git a/pkg/cmd/ssh-key/list/list_test.go b/pkg/cmd/ssh-key/list/list_test.go index 77ee26600af..a65eaa9aefb 100644 --- a/pkg/cmd/ssh-key/list/list_test.go +++ b/pkg/cmd/ssh-key/list/list_test.go @@ -1,14 +1,15 @@ package list import ( + "context" "fmt" - "net/http" "testing" "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" ) @@ -25,7 +26,7 @@ func TestListRun(t *testing.T) { { name: "list authentication and signing keys; in tty", opts: ListOptions{ - HTTPClient: func() (*http.Client, error) { + GitHubREST: func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { createdAt := time.Now().Add(time.Duration(-24) * time.Hour) reg := &httpmock.Registry{} reg.Register( @@ -56,7 +57,7 @@ func TestListRun(t *testing.T) { } ]`, createdAt.Format(time.RFC3339))), ) - return &http.Client{Transport: reg}, nil + return httpmock.RESTClientFunc(reg)(host, opts...) }, }, isTTY: true, @@ -71,7 +72,7 @@ func TestListRun(t *testing.T) { { name: "list authentication and signing keys; in non-tty", opts: ListOptions{ - HTTPClient: func() (*http.Client, error) { + GitHubREST: func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { createdAt, _ := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00") reg := &httpmock.Registry{} reg.Register( @@ -102,7 +103,7 @@ func TestListRun(t *testing.T) { } ]`, createdAt.Format(time.RFC3339))), ) - return &http.Client{Transport: reg}, nil + return httpmock.RESTClientFunc(reg)(host, opts...) }, }, isTTY: false, @@ -116,7 +117,7 @@ func TestListRun(t *testing.T) { { name: "only authentication ssh keys are available", opts: ListOptions{ - HTTPClient: func() (*http.Client, error) { + GitHubREST: func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { createdAt := time.Now().Add(time.Duration(-24) * time.Hour) reg := &httpmock.Registry{} reg.Register( @@ -134,7 +135,7 @@ func TestListRun(t *testing.T) { httpmock.REST("GET", "user/ssh_signing_keys"), httpmock.StatusStringResponse(404, "Not Found"), ) - return &http.Client{Transport: reg}, nil + return httpmock.RESTClientFunc(reg)(host, opts...) }, }, wantStdout: heredoc.Doc(` @@ -150,7 +151,7 @@ func TestListRun(t *testing.T) { { name: "only signing ssh keys are available", opts: ListOptions{ - HTTPClient: func() (*http.Client, error) { + GitHubREST: func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { createdAt := time.Now().Add(time.Duration(-24) * time.Hour) reg := &httpmock.Registry{} reg.Register( @@ -168,7 +169,7 @@ func TestListRun(t *testing.T) { httpmock.REST("GET", "user/keys"), httpmock.StatusStringResponse(404, "Not Found"), ) - return &http.Client{Transport: reg}, nil + return httpmock.RESTClientFunc(reg)(host, opts...) }, }, wantStdout: heredoc.Doc(` @@ -184,7 +185,7 @@ func TestListRun(t *testing.T) { { name: "no keys tty", opts: ListOptions{ - HTTPClient: func() (*http.Client, error) { + GitHubREST: func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { reg := &httpmock.Registry{} reg.Register( httpmock.REST("GET", "user/keys"), @@ -194,7 +195,7 @@ func TestListRun(t *testing.T) { httpmock.REST("GET", "user/ssh_signing_keys"), httpmock.StringResponse(`[]`), ) - return &http.Client{Transport: reg}, nil + return httpmock.RESTClientFunc(reg)(host, opts...) }, }, wantStdout: "", @@ -205,7 +206,7 @@ func TestListRun(t *testing.T) { { name: "no keys non-tty", opts: ListOptions{ - HTTPClient: func() (*http.Client, error) { + GitHubREST: func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { reg := &httpmock.Registry{} reg.Register( httpmock.REST("GET", "user/keys"), @@ -215,7 +216,7 @@ func TestListRun(t *testing.T) { httpmock.REST("GET", "user/ssh_signing_keys"), httpmock.StringResponse(`[]`), ) - return &http.Client{Transport: reg}, nil + return httpmock.RESTClientFunc(reg)(host, opts...) }, }, wantStdout: "", @@ -236,7 +237,7 @@ func TestListRun(t *testing.T) { opts.IO = ios opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } - err := listRun(&opts) + err := listRun(context.Background(), &opts) if (err != nil) != tt.wantErr { t.Errorf("listRun() return error: %v", err) return diff --git a/pkg/cmd/ssh-key/shared/user_keys.go b/pkg/cmd/ssh-key/shared/user_keys.go index 8cc5a93fdc6..f309bfa90e9 100644 --- a/pkg/cmd/ssh-key/shared/user_keys.go +++ b/pkg/cmd/ssh-key/shared/user_keys.go @@ -1,10 +1,11 @@ package shared import ( + "context" "net/http" "time" - "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -21,7 +22,7 @@ type sshKey struct { CreatedAt time.Time `json:"created_at"` } -func UserKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) { +func UserKeys(ctx context.Context, client *githubrest.Client, userHandle string, reqOpts ...githubrest.RequestOption) ([]sshKey, error) { u, err := safeurl.JoinPath("user", "keys") if err != nil { return nil, err @@ -34,7 +35,7 @@ func UserKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error } u.SetQuery("per_page", "100") - keys, err := getUserKeys(httpClient, host, u) + keys, err := getUserKeys(ctx, client, u, reqOpts...) if err != nil { return nil, err @@ -47,7 +48,7 @@ func UserKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error return keys, nil } -func UserSigningKeys(httpClient *http.Client, host, userHandle string) ([]sshKey, error) { +func UserSigningKeys(ctx context.Context, client *githubrest.Client, userHandle string, reqOpts ...githubrest.RequestOption) ([]sshKey, error) { u, err := safeurl.JoinPath("user", "ssh_signing_keys") if err != nil { return nil, err @@ -60,7 +61,7 @@ func UserSigningKeys(httpClient *http.Client, host, userHandle string) ([]sshKey } u.SetQuery("per_page", "100") - keys, err := getUserKeys(httpClient, host, u) + keys, err := getUserKeys(ctx, client, u, reqOpts...) if err != nil { return nil, err @@ -73,15 +74,15 @@ func UserSigningKeys(httpClient *http.Client, host, userHandle string) ([]sshKey return keys, nil } -func getUserKeys(httpClient *http.Client, hostname string, u safeurl.SafeURL) ([]sshKey, error) { +func getUserKeys(ctx context.Context, client *githubrest.Client, u safeurl.SafeURL, reqOpts ...githubrest.RequestOption) ([]sshKey, error) { var keys []sshKey - // 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(hostname, http.MethodGet, u.String(), nil, &keys) + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil, reqOpts...) if err != nil { return nil, err } + if _, err := client.Do(req, &keys); err != nil { + return nil, err + } return keys, nil } diff --git a/pkg/cmd/ssh-key/shared/user_keys_test.go b/pkg/cmd/ssh-key/shared/user_keys_test.go index f64c0b67ab1..5251626a704 100644 --- a/pkg/cmd/ssh-key/shared/user_keys_test.go +++ b/pkg/cmd/ssh-key/shared/user_keys_test.go @@ -1,6 +1,7 @@ package shared import ( + "context" "net/http" "testing" @@ -18,7 +19,9 @@ func TestUserKeysHTTPError(t *testing.T) { httpmock.StatusStringResponse(http.StatusNotFound, `{"message":"Not Found"}`), ) - keys, err := UserKeys(&http.Client{Transport: reg}, "github.com", "") + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + keys, err := UserKeys(context.Background(), client, "") assert.Nil(t, keys) var httpErr *githubrest.ErrorResponse diff --git a/pkg/cmd/status/status.go b/pkg/cmd/status/status.go index 12c3bc385ae..16c95af4f69 100644 --- a/pkg/cmd/status/status.go +++ b/pkg/cmd/status/status.go @@ -33,6 +33,7 @@ type hostConfig interface { type StatusOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) HostConfig hostConfig CachedClient func(*http.Client, time.Duration) *http.Client IO *iostreams.IOStreams @@ -47,6 +48,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co }, } opts.HttpClient = f.HttpClient + opts.GitHubREST = f.GitHubREST opts.IO = f.IOStreams cmd := &cobra.Command{ Use: "status", @@ -76,7 +78,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co return runF(opts) } - return statusRun(opts) + return statusRun(cmd.Context(), opts) }, } @@ -172,6 +174,7 @@ type stringSet interface { type StatusGetter struct { Client *http.Client + restClient *githubrest.Client cachedClient func(*http.Client, time.Duration) *http.Client host string Org string @@ -189,9 +192,10 @@ type StatusGetter struct { usernameMu sync.Mutex } -func NewStatusGetter(client *http.Client, hostname string, opts *StatusOptions) *StatusGetter { +func NewStatusGetter(client *http.Client, restClient *githubrest.Client, hostname string, opts *StatusOptions) *StatusGetter { return &StatusGetter{ Client: client, + restClient: restClient, Org: opts.Org, Exclude: opts.Exclude, cachedClient: opts.CachedClient, @@ -235,7 +239,7 @@ func (s *StatusGetter) CurrentUsername() (string, error) { return currentUsername, nil } -func (s *StatusGetter) ActualMention(commentURL safeurl.SafeURL) (string, error) { +func (s *StatusGetter) ActualMention(ctx context.Context, commentURL safeurl.SafeURL) (string, error) { currentUsername, err := s.CurrentUsername() if err != nil { return "", err @@ -243,12 +247,14 @@ func (s *StatusGetter) ActualMention(commentURL safeurl.SafeURL) (string, error) // long cache period since once a comment is looked up, it never needs to be // consulted again. - cachedClient := s.CachedClient(time.Hour * 24 * 30) - c := api.NewClientFromHTTP(cachedClient) resp := struct { Body string }{} - if err := c.REST(s.hostname(), "GET", commentURL.String(), nil, &resp); err != nil { + req, err := s.restClient.NewRequest(ctx, "GET", commentURL.String(), nil, githubrest.WithCacheTTL(time.Hour*24*30)) + if err != nil { + return "", err + } + if _, err := s.restClient.Do(req, &resp); err != nil { return "", err } @@ -263,12 +269,11 @@ func (s *StatusGetter) ActualMention(commentURL safeurl.SafeURL) (string, error) // work // Populate .Mentions -func (s *StatusGetter) LoadNotifications() error { +func (s *StatusGetter) LoadNotifications(ctx context.Context) error { perPage := 100 - c := api.NewClientFromHTTP(s.Client) fetchWorkers := 10 - ctx, abortFetching := context.WithCancel(context.Background()) + fetchCtx, abortFetching := context.WithCancel(ctx) defer abortFetching() toFetch := make(chan Notification) fetched := make(chan StatusItem) @@ -278,13 +283,13 @@ func (s *StatusGetter) LoadNotifications() error { wg.Go(func() error { for { select { - case <-ctx.Done(): + case <-fetchCtx.Done(): return nil case n, ok := <-toFetch: if !ok { return nil } - actual, err := s.ActualMention(safeurl.NewImmutableSafeURL(n.Subject.LatestCommentURL)) + actual, err := s.ActualMention(ctx, safeurl.NewImmutableSafeURL(n.Subject.LatestCommentURL)) if err != nil { var httpErr *githubrest.ErrorResponse @@ -344,7 +349,11 @@ func (s *StatusGetter) LoadNotifications() error { var p safeurl.SafeURL = u for pages := 0; pages < 3; pages++ { var resp []Notification - next, err := c.RESTWithNext(s.hostname(), "GET", p.String(), nil, &resp) + req, err := s.restClient.NewRequest(ctx, "GET", p.String(), nil) + if err != nil { + return err + } + httpResp, err := s.restClient.Do(req, &resp) if err != nil { var httpErr *githubrest.ErrorResponse if !errors.As(err, &httpErr) || httpErr.StatusCode != 404 { @@ -367,6 +376,10 @@ func (s *StatusGetter) LoadNotifications() error { toFetch <- n } + next := "" + if httpResp != nil { + next = httpResp.NextPage() + } if next == "" || len(resp) < perPage { break } @@ -532,9 +545,8 @@ func (s *StatusGetter) LoadSearchResults() error { } // Populate .RepoActivity -func (s *StatusGetter) LoadEvents() error { +func (s *StatusGetter) LoadEvents(ctx context.Context) error { perPage := 100 - c := api.NewClientFromHTTP(s.Client) currentUsername, err := s.CurrentUsername() if err != nil { @@ -551,7 +563,11 @@ func (s *StatusGetter) LoadEvents() error { u.SetQuery("per_page", strconv.Itoa(perPage)) var p safeurl.SafeURL = u for pages < 2 { - next, err := c.RESTWithNext(s.hostname(), "GET", p.String(), nil, &resp) + req, err := s.restClient.NewRequest(ctx, "GET", p.String(), nil) + if err != nil { + return err + } + httpResp, err := s.restClient.Do(req, &resp) if err != nil { var httpErr *githubrest.ErrorResponse if !errors.As(err, &httpErr) || httpErr.StatusCode != 404 { @@ -559,6 +575,10 @@ func (s *StatusGetter) LoadEvents() error { } } events = append(events, resp...) + next := "" + if httpResp != nil { + next = httpResp.NextPage() + } if next == "" || len(resp) < perPage { break } @@ -634,7 +654,7 @@ func (s *StatusGetter) HasAuthErrors() bool { return s.authErrors != nil && s.authErrors.Len() > 0 } -func statusRun(opts *StatusOptions) error { +func statusRun(ctx context.Context, opts *StatusOptions) error { client, err := opts.HttpClient() if err != nil { return fmt.Errorf("could not create client: %w", err) @@ -642,19 +662,24 @@ func statusRun(opts *StatusOptions) error { hostname, _ := opts.HostConfig.DefaultHost() - sg := NewStatusGetter(client, hostname, opts) + restClient, err := opts.GitHubREST(hostname) + if err != nil { + return fmt.Errorf("could not create client: %w", err) + } + + sg := NewStatusGetter(client, restClient, hostname, opts) // TODO break out sections into individual subcommands g := new(errgroup.Group) g.Go(func() error { - if err := sg.LoadNotifications(); err != nil { + if err := sg.LoadNotifications(ctx); err != nil { return fmt.Errorf("could not load notifications: %w", err) } return nil }) g.Go(func() error { - if err := sg.LoadEvents(); err != nil { + if err := sg.LoadEvents(ctx); err != nil { return fmt.Errorf("could not load events: %w", err) } return nil diff --git a/pkg/cmd/status/status_test.go b/pkg/cmd/status/status_test.go index 685ad5be74b..3cebc419c2f 100644 --- a/pkg/cmd/status/status_test.go +++ b/pkg/cmd/status/status_test.go @@ -2,6 +2,7 @@ package status import ( "bytes" + "context" "io" "net/http" "strings" @@ -458,6 +459,7 @@ func TestStatusRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.CachedClient = func(c *http.Client, _ time.Duration) *http.Client { return c } @@ -467,7 +469,7 @@ func TestStatusRun(t *testing.T) { tt.opts.IO = ios t.Run(tt.name, func(t *testing.T) { - err := statusRun(tt.opts) + err := statusRun(context.Background(), tt.opts) if tt.wantErrMsg != "" { assert.Equal(t, tt.wantErrMsg, err.Error()) return diff --git a/pkg/cmd/variable/delete/delete.go b/pkg/cmd/variable/delete/delete.go index d8c12d28ddf..a10d48675af 100644 --- a/pkg/cmd/variable/delete/delete.go +++ b/pkg/cmd/variable/delete/delete.go @@ -1,13 +1,14 @@ package delete import ( + "context" "fmt" "net/http" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/variable/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -16,7 +17,7 @@ import ( ) type DeleteOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Config func() (gh.Config, error) BaseRepo func() (ghrepo.Interface, error) @@ -30,7 +31,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co opts := &DeleteOptions{ IO: f.IOStreams, Config: f.Config, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -57,7 +58,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return runF(opts) } - return removeRun(opts) + return removeRun(cmd.Context(), opts) }, Aliases: []string{ "remove", @@ -69,13 +70,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co return cmd } -func removeRun(opts *DeleteOptions) error { - c, err := opts.HttpClient() - if err != nil { - return fmt.Errorf("could not create http client: %w", err) - } - client := api.NewClientFromHTTP(c) - +func removeRun(ctx context.Context, opts *DeleteOptions) error { orgName := opts.OrgName envName := opts.EnvName @@ -114,8 +109,16 @@ func removeRun(opts *DeleteOptions) error { return err } - err = client.REST(host, "DELETE", path.String(), nil, nil) + client, err := opts.GitHubREST(host) + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + + req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), nil) if err != nil { + return err + } + if _, err := client.Do(req, nil); err != nil { return fmt.Errorf("failed to delete variable %s: %w", opts.VariableName, err) } diff --git a/pkg/cmd/variable/delete/delete_test.go b/pkg/cmd/variable/delete/delete_test.go index d00bef8fb73..cd8b85a3723 100644 --- a/pkg/cmd/variable/delete/delete_test.go +++ b/pkg/cmd/variable/delete/delete_test.go @@ -2,7 +2,7 @@ package delete import ( "bytes" - "net/http" + "context" "testing" "github.com/cli/cli/v2/internal/config" @@ -161,9 +161,7 @@ func TestRemoveRun(t *testing.T) { ios, _, _, _ := iostreams.Test() tt.opts.IO = ios - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -171,7 +169,7 @@ func TestRemoveRun(t *testing.T) { return ghrepo.FromFullNameWithHost("owner/repo", tt.host) } - err := removeRun(tt.opts) + err := removeRun(context.Background(), tt.opts) require.NoError(t, err) }) } diff --git a/pkg/cmd/variable/get/get.go b/pkg/cmd/variable/get/get.go index 60c9cbe0659..0bd07774c9b 100644 --- a/pkg/cmd/variable/get/get.go +++ b/pkg/cmd/variable/get/get.go @@ -1,12 +1,12 @@ package get import ( + "context" "errors" "fmt" "net/http" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" @@ -18,7 +18,7 @@ import ( ) type GetOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Config func() (gh.Config, error) BaseRepo func() (ghrepo.Interface, error) @@ -34,7 +34,7 @@ func NewCmdGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command opts := &GetOptions{ IO: f.IOStreams, Config: f.Config, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -61,7 +61,7 @@ func NewCmdGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command return runF(opts) } - return getRun(opts) + return getRun(cmd.Context(), opts) }, } cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Get a variable for an organization") @@ -71,13 +71,7 @@ func NewCmdGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command return cmd } -func getRun(opts *GetOptions) error { - c, err := opts.HttpClient() - if err != nil { - return fmt.Errorf("could not create http client: %w", err) - } - client := api.NewClientFromHTTP(c) - +func getRun(ctx context.Context, opts *GetOptions) error { orgName := opts.OrgName envName := opts.EnvName @@ -116,8 +110,17 @@ func getRun(opts *GetOptions) error { return err } + client, err := opts.GitHubREST(host) + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + var variable shared.Variable - if err = client.REST(host, "GET", path.String(), nil, &variable); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) + if err != nil { + return err + } + if _, err = client.Do(req, &variable); err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { return fmt.Errorf("variable %s was not found", opts.VariableName) @@ -128,7 +131,7 @@ func getRun(opts *GetOptions) error { if opts.Exporter != nil { if variable.SelectedReposURL != "" { - count, err := shared.SelectedRepositoryCount(client, host, safeurl.NewImmutableSafeURL(variable.SelectedReposURL)) + count, err := shared.SelectedRepositoryCount(ctx, client, safeurl.NewImmutableSafeURL(variable.SelectedReposURL)) if err != nil { return fmt.Errorf("failed determining selected repositories for %s: %w", variable.Name, err) } diff --git a/pkg/cmd/variable/get/get_test.go b/pkg/cmd/variable/get/get_test.go index 82b602c9e30..acc8bbe1338 100644 --- a/pkg/cmd/variable/get/get_test.go +++ b/pkg/cmd/variable/get/get_test.go @@ -2,8 +2,8 @@ package get import ( "bytes" + "context" "fmt" - "net/http" "testing" "time" @@ -264,9 +264,7 @@ func Test_getRun(t *testing.T) { tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullNameWithHost("owner/repo", tt.host) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -277,7 +275,7 @@ func Test_getRun(t *testing.T) { tt.opts.Exporter = exporter } - err := getRun(tt.opts) + err := getRun(context.Background(), tt.opts) if err != nil { require.EqualError(t, tt.wantErr, err.Error()) return diff --git a/pkg/cmd/variable/list/list.go b/pkg/cmd/variable/list/list.go index 7624da22577..e8e0d7a4947 100644 --- a/pkg/cmd/variable/list/list.go +++ b/pkg/cmd/variable/list/list.go @@ -1,6 +1,7 @@ package list import ( + "context" "fmt" "net/http" "slices" @@ -8,9 +9,9 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/variable/shared" @@ -20,7 +21,7 @@ import ( ) type ListOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Config func() (gh.Config, error) BaseRepo func() (ghrepo.Interface, error) @@ -38,7 +39,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman opts := &ListOptions{ IO: f.IOStreams, Config: f.Config, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Now: time.Now, } @@ -65,7 +66,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return runF(opts) } - return listRun(opts) + return listRun(cmd.Context(), opts) }, } @@ -76,17 +77,13 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return cmd } -func listRun(opts *ListOptions) error { - client, err := opts.HttpClient() - if err != nil { - return fmt.Errorf("could not create http client: %w", err) - } - +func listRun(ctx context.Context, opts *ListOptions) error { orgName := opts.OrgName envName := opts.EnvName var baseRepo ghrepo.Interface if orgName == "" { + var err error baseRepo, err = opts.BaseRepo() if err != nil { return err @@ -115,9 +112,19 @@ func listRun(opts *ListOptions) error { var variables []shared.Variable switch variableEntity { case shared.Repository: - variables, err = getRepoVariables(client, baseRepo) + var client *githubrest.Client + client, err = opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + variables, err = getRepoVariables(ctx, client, baseRepo) case shared.Environment: - variables, err = getEnvVariables(client, baseRepo, envName) + var client *githubrest.Client + client, err = opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + variables, err = getEnvVariables(ctx, client, baseRepo, envName) case shared.Organization: var cfg gh.Config var host string @@ -126,7 +133,12 @@ func listRun(opts *ListOptions) error { return err } host, _ = cfg.Authentication().DefaultHost() - variables, err = getOrgVariables(client, host, orgName, showSelectedRepoInfo) + var client *githubrest.Client + client, err = opts.GitHubREST(host) + if err != nil { + return fmt.Errorf("could not create http client: %w", err) + } + variables, err = getOrgVariables(ctx, client, orgName, showSelectedRepoInfo) } if err != nil { @@ -193,38 +205,37 @@ func fmtVisibility(s shared.Variable) string { return "" } -func getRepoVariables(client *http.Client, repo ghrepo.Interface) ([]shared.Variable, error) { +func getRepoVariables(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface) ([]shared.Variable, error) { u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "variables") if err != nil { return nil, err } - return getVariables(client, repo.RepoHost(), u) + return getVariables(ctx, client, u) } -func getEnvVariables(client *http.Client, repo ghrepo.Interface, envName string) ([]shared.Variable, error) { +func getEnvVariables(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, envName string) ([]shared.Variable, error) { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "environments", envName, "variables") if err != nil { return nil, err } - return getVariables(client, repo.RepoHost(), path) + return getVariables(ctx, client, path) } -func getOrgVariables(client *http.Client, host, orgName string, showSelectedRepoInfo bool) ([]shared.Variable, error) { +func getOrgVariables(ctx context.Context, client *githubrest.Client, orgName string, showSelectedRepoInfo bool) ([]shared.Variable, error) { u, err := safeurl.JoinPath("orgs", orgName, "actions", "variables") if err != nil { return nil, err } - variables, err := getVariables(client, host, u) + variables, err := getVariables(ctx, client, u) if err != nil { return nil, err } - apiClient := api.NewClientFromHTTP(client) if showSelectedRepoInfo { for i := range variables { if variables[i].SelectedReposURL == "" { continue } - count, err := shared.SelectedRepositoryCount(apiClient, host, safeurl.NewImmutableSafeURL(variables[i].SelectedReposURL)) + count, err := shared.SelectedRepositoryCount(ctx, client, safeurl.NewImmutableSafeURL(variables[i].SelectedReposURL)) if err != nil { return nil, fmt.Errorf("failed determining selected repositories for %s: %w", variables[i].Name, err) } @@ -234,20 +245,23 @@ func getOrgVariables(client *http.Client, host, orgName string, showSelectedRepo return variables, nil } -func getVariables(client *http.Client, host string, u *safeurl.MutableSafeURL) ([]shared.Variable, error) { +func getVariables(ctx context.Context, client *githubrest.Client, u *safeurl.MutableSafeURL) ([]shared.Variable, error) { var results []shared.Variable - apiClient := api.NewClientFromHTTP(client) u.SetQuery("per_page", "100") var pageURL safeurl.SafeURL = u for pageURL.String() != "" { response := struct { Variables []shared.Variable }{} - next, err := apiClient.RESTWithNext(host, "GET", pageURL.String(), nil, &response) + req, err := client.NewRequest(ctx, http.MethodGet, pageURL.String(), nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req, &response) if err != nil { return nil, err } - pageURL = safeurl.NewImmutableSafeURL(next) + pageURL = safeurl.NewImmutableSafeURL(resp.NextPage()) results = append(results, response.Variables...) } return results, nil diff --git a/pkg/cmd/variable/list/list_test.go b/pkg/cmd/variable/list/list_test.go index 46c68b1f53e..d8fcb7e7526 100644 --- a/pkg/cmd/variable/list/list_test.go +++ b/pkg/cmd/variable/list/list_test.go @@ -2,8 +2,8 @@ package list import ( "bytes" + "context" "fmt" - "net/http" "net/url" "strings" "testing" @@ -275,9 +275,7 @@ func Test_listRun(t *testing.T) { tt.opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -292,7 +290,7 @@ func Test_listRun(t *testing.T) { tt.opts.Exporter = jsonExporter } - err := listRun(tt.opts) + err := listRun(context.Background(), tt.opts) assert.NoError(t, err) expected := fmt.Sprintf("%s\n", strings.Join(tt.wantOut, "\n")) @@ -397,9 +395,7 @@ func Test_listRun_populatesNumSelectedReposIfRequired(t *testing.T) { opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") } - opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + opts.GitHubREST = httpmock.RESTClientFunc(reg) opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -408,7 +404,7 @@ func Test_listRun_populatesNumSelectedReposIfRequired(t *testing.T) { return t } - require.NoError(t, listRun(opts)) + require.NoError(t, listRun(context.Background(), opts)) if tt.wantPopulated { // There should be 2 requests; one to get the variables list and @@ -430,16 +426,17 @@ func Test_getVariables_pagination(t *testing.T) { httpmock.WithHeader( httpmock.StringResponse(`{"variables":[{},{}]}`), "Link", - `; rel="previous", ; rel="next"`), + `; rel="previous", ; rel="next"`), ) reg.Register( httpmock.REST("GET", "page/2"), httpmock.StringResponse(`{"variables":[{},{}]}`), ) - client := &http.Client{Transport: reg} + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) u, err := safeurl.JoinPath("path", "to") require.NoError(t, err) - variables, err := getVariables(client, "github.com", u) + variables, err := getVariables(context.Background(), client, u) assert.NoError(t, err) assert.Equal(t, 4, len(variables)) } diff --git a/pkg/cmd/variable/set/http.go b/pkg/cmd/variable/set/http.go index c45a25135f6..3e00e46c77c 100644 --- a/pkg/cmd/variable/set/http.go +++ b/pkg/cmd/variable/set/http.go @@ -2,9 +2,11 @@ package set import ( "bytes" + "context" "encoding/json" "errors" "fmt" + "net/http" "strconv" "github.com/cli/cli/v2/api" @@ -43,40 +45,43 @@ type setResult struct { Operation string } -func setVariable(client *api.Client, host string, opts setOptions) setResult { +// setVariable creates or updates a variable. It takes both a REST client, which +// performs the create and update requests, and a GraphQL client, which resolves +// a repository name to the ID that the environment variable endpoint requires. +func setVariable(ctx context.Context, client *githubrest.Client, gqlClient *api.Client, opts setOptions) setResult { var err error var postErr *githubrest.ErrorResponse result := setResult{Operation: createdOperation, Key: opts.Key} switch opts.Entity { case shared.Organization: - if err = postOrgVariable(client, host, opts.Organization, opts.Visibility, opts.Key, opts.Value, opts.RepositoryIDs); err == nil { + if err = postOrgVariable(ctx, client, opts.Organization, opts.Visibility, opts.Key, opts.Value, opts.RepositoryIDs); err == nil { return result } else if errors.As(err, &postErr) && postErr.StatusCode == 409 { // Server will return a 409 if variable already exists result.Operation = updatedOperation - err = patchOrgVariable(client, host, opts.Organization, opts.Visibility, opts.Key, opts.Value, opts.RepositoryIDs) + err = patchOrgVariable(ctx, client, opts.Organization, opts.Visibility, opts.Key, opts.Value, opts.RepositoryIDs) } case shared.Environment: var ids []int64 - ids, err = api.GetRepoIDs(client, opts.Repository.RepoHost(), []ghrepo.Interface{opts.Repository}) + ids, err = api.GetRepoIDs(gqlClient, opts.Repository.RepoHost(), []ghrepo.Interface{opts.Repository}) if err != nil || len(ids) != 1 { err = fmt.Errorf("failed to look up repository %s: %w", ghrepo.FullName(opts.Repository), err) break } - if err = postEnvVariable(client, opts.Repository.RepoHost(), ids[0], opts.Environment, opts.Key, opts.Value); err == nil { + if err = postEnvVariable(ctx, client, ids[0], opts.Environment, opts.Key, opts.Value); err == nil { return result } else if errors.As(err, &postErr) && postErr.StatusCode == 409 { // Server will return a 409 if variable already exists result.Operation = updatedOperation - err = patchEnvVariable(client, opts.Repository.RepoHost(), ids[0], opts.Environment, opts.Key, opts.Value) + err = patchEnvVariable(ctx, client, ids[0], opts.Environment, opts.Key, opts.Value) } default: - if err = postRepoVariable(client, opts.Repository, opts.Key, opts.Value); err == nil { + if err = postRepoVariable(ctx, client, opts.Repository, opts.Key, opts.Value); err == nil { return result } else if errors.As(err, &postErr) && postErr.StatusCode == 409 { // Server will return a 409 if variable already exists result.Operation = updatedOperation - err = patchRepoVariable(client, opts.Repository, opts.Key, opts.Value) + err = patchRepoVariable(ctx, client, opts.Repository, opts.Key, opts.Value) } } if err != nil { @@ -85,16 +90,20 @@ func setVariable(client *api.Client, host string, opts setOptions) setResult { return result } -func postVariable(client *api.Client, host string, path safeurl.SafeURL, payload interface{}) error { +func postVariable(ctx context.Context, client *githubrest.Client, path safeurl.SafeURL, payload interface{}) error { payloadBytes, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to serialize: %w", err) } - requestBody := bytes.NewReader(payloadBytes) - return client.REST(host, "POST", path.String(), requestBody, nil) + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), bytes.NewReader(payloadBytes)) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err } -func postOrgVariable(client *api.Client, host, orgName, visibility, variableName, value string, repositoryIDs []int64) error { +func postOrgVariable(ctx context.Context, client *githubrest.Client, orgName, visibility, variableName, value string, repositoryIDs []int64) error { payload := setPayload{ Name: variableName, Value: value, @@ -105,10 +114,10 @@ func postOrgVariable(client *api.Client, host, orgName, visibility, variableName if err != nil { return err } - return postVariable(client, host, path, payload) + return postVariable(ctx, client, path, payload) } -func postEnvVariable(client *api.Client, host string, repoID int64, envName, variableName, value string) error { +func postEnvVariable(ctx context.Context, client *githubrest.Client, repoID int64, envName, variableName, value string) error { payload := setPayload{ Name: variableName, Value: value, @@ -117,10 +126,10 @@ func postEnvVariable(client *api.Client, host string, repoID int64, envName, var if err != nil { return err } - return postVariable(client, host, path, payload) + return postVariable(ctx, client, path, payload) } -func postRepoVariable(client *api.Client, repo ghrepo.Interface, variableName, value string) error { +func postRepoVariable(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, variableName, value string) error { payload := setPayload{ Name: variableName, Value: value, @@ -129,19 +138,23 @@ func postRepoVariable(client *api.Client, repo ghrepo.Interface, variableName, v if err != nil { return err } - return postVariable(client, repo.RepoHost(), path, payload) + return postVariable(ctx, client, path, payload) } -func patchVariable(client *api.Client, host string, path safeurl.SafeURL, payload interface{}) error { +func patchVariable(ctx context.Context, client *githubrest.Client, path safeurl.SafeURL, payload interface{}) error { payloadBytes, err := json.Marshal(payload) if err != nil { return fmt.Errorf("failed to serialize: %w", err) } - requestBody := bytes.NewReader(payloadBytes) - return client.REST(host, "PATCH", path.String(), requestBody, nil) + req, err := client.NewRequest(ctx, http.MethodPatch, path.String(), bytes.NewReader(payloadBytes)) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err } -func patchOrgVariable(client *api.Client, host, orgName, visibility, variableName, value string, repositoryIDs []int64) error { +func patchOrgVariable(ctx context.Context, client *githubrest.Client, orgName, visibility, variableName, value string, repositoryIDs []int64) error { payload := setPayload{ Value: value, Visibility: visibility, @@ -151,10 +164,10 @@ func patchOrgVariable(client *api.Client, host, orgName, visibility, variableNam if err != nil { return err } - return patchVariable(client, host, path, payload) + return patchVariable(ctx, client, path, payload) } -func patchEnvVariable(client *api.Client, host string, repoID int64, envName, variableName, value string) error { +func patchEnvVariable(ctx context.Context, client *githubrest.Client, repoID int64, envName, variableName, value string) error { payload := setPayload{ Value: value, } @@ -162,10 +175,10 @@ func patchEnvVariable(client *api.Client, host string, repoID int64, envName, va if err != nil { return err } - return patchVariable(client, host, path, payload) + return patchVariable(ctx, client, path, payload) } -func patchRepoVariable(client *api.Client, repo ghrepo.Interface, variableName, value string) error { +func patchRepoVariable(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, variableName, value string) error { payload := setPayload{ Value: value, } @@ -173,5 +186,5 @@ func patchRepoVariable(client *api.Client, repo ghrepo.Interface, variableName, if err != nil { return err } - return patchVariable(client, repo.RepoHost(), path, payload) + return patchVariable(ctx, client, path, payload) } diff --git a/pkg/cmd/variable/set/set.go b/pkg/cmd/variable/set/set.go index 57f21822101..d484dffa720 100644 --- a/pkg/cmd/variable/set/set.go +++ b/pkg/cmd/variable/set/set.go @@ -2,6 +2,7 @@ package set import ( "bytes" + "context" "errors" "fmt" "io" @@ -13,6 +14,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/variable/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -26,6 +28,7 @@ type iprompter interface { type SetOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams Config func() (gh.Config, error) BaseRepo func() (ghrepo.Interface, error) @@ -45,6 +48,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Prompter: f.Prompter, } @@ -125,7 +129,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command return runF(opts) } - return setRun(opts) + return setRun(cmd.Context(), opts) }, } @@ -139,7 +143,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command return cmd } -func setRun(opts *SetOptions) error { +func setRun(ctx context.Context, opts *SetOptions) error { variables, err := getVariablesFromOptions(opts) if err != nil { return err @@ -149,7 +153,9 @@ func setRun(opts *SetOptions) error { if err != nil { return fmt.Errorf("could not set http client: %w", err) } - client := api.NewClientFromHTTP(c) + // The GraphQL client is retained because mapping repository names to IDs is + // a GraphQL query; the REST client below handles the variable requests. + gqlClient := api.NewClientFromHTTP(c) orgName := opts.OrgName envName := opts.EnvName @@ -170,13 +176,18 @@ func setRun(opts *SetOptions) error { host, _ = cfg.Authentication().DefaultHost() } + restClient, err := opts.GitHubREST(host) + if err != nil { + return fmt.Errorf("could not set http client: %w", err) + } + entity, err := shared.GetVariableEntity(orgName, envName) if err != nil { return err } opts.IO.StartProgressIndicator() - repositoryIDs, err := getRepoIds(client, host, opts.OrgName, opts.RepositoryNames) + repositoryIDs, err := getRepoIds(gqlClient, host, opts.OrgName, opts.RepositoryNames) opts.IO.StopProgressIndicator() if err != nil { return err @@ -197,7 +208,7 @@ func setRun(opts *SetOptions) error { Value: v, Visibility: opts.Visibility, } - setc <- setVariable(client, host, setOpts) + setc <- setVariable(ctx, restClient, gqlClient, setOpts) }() } diff --git a/pkg/cmd/variable/set/set_test.go b/pkg/cmd/variable/set/set_test.go index 4e77d5900f9..11a1c7a1d96 100644 --- a/pkg/cmd/variable/set/set_test.go +++ b/pkg/cmd/variable/set/set_test.go @@ -2,6 +2,7 @@ package set import ( "bytes" + "context" "encoding/json" "io" "net/http" @@ -211,7 +212,8 @@ func Test_setRun_repo(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, @@ -220,7 +222,7 @@ func Test_setRun_repo(t *testing.T) { Body: "a variable", } - err := setRun(opts) + err := setRun(context.Background(), opts) if tt.wantErr { assert.Error(t, err) return @@ -284,7 +286,8 @@ func Test_setRun_env(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.FromFullName("owner/repo") }, @@ -294,7 +297,7 @@ func Test_setRun_env(t *testing.T) { EnvName: "release", } - err := setRun(opts) + err := setRun(context.Background(), opts) if tt.wantErr { assert.Error(t, err) return @@ -395,6 +398,7 @@ func Test_setRun_org(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -402,7 +406,7 @@ func Test_setRun_org(t *testing.T) { tt.opts.VariableName = "cool_variable" tt.opts.Body = "a variable" - err := setRun(tt.opts) + err := setRun(context.Background(), tt.opts) assert.NoError(t, err) data, err := io.ReadAll(reg.Requests[len(reg.Requests)-1].Body) diff --git a/pkg/cmd/variable/shared/shared.go b/pkg/cmd/variable/shared/shared.go index de449c9864f..00c4c1b9737 100644 --- a/pkg/cmd/variable/shared/shared.go +++ b/pkg/cmd/variable/shared/shared.go @@ -1,10 +1,12 @@ package shared import ( + "context" "errors" + "net/http" "time" - "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" ) @@ -68,11 +70,15 @@ func GetVariableEntity(orgName, envName string) (VariableEntity, error) { // SelectedRepositoryCount returns how many repositories the variable is visible to, fetched from the // given entrusted URL. Callers own reading the URL off the variable and writing the result back. -func SelectedRepositoryCount(apiClient *api.Client, host string, selectedReposURL safeurl.SafeURL) (int, error) { +func SelectedRepositoryCount(ctx context.Context, client *githubrest.Client, selectedReposURL safeurl.SafeURL) (int, error) { response := struct { TotalCount int `json:"total_count"` }{} - if err := apiClient.REST(host, "GET", selectedReposURL.String(), nil, &response); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, selectedReposURL.String(), nil) + if err != nil { + return 0, err + } + if _, err := client.Do(req, &response); err != nil { return 0, err } return response.TotalCount, nil diff --git a/pkg/cmd/workflow/disable/disable.go b/pkg/cmd/workflow/disable/disable.go index 53882a17e17..68a41057047 100644 --- a/pkg/cmd/workflow/disable/disable.go +++ b/pkg/cmd/workflow/disable/disable.go @@ -1,13 +1,14 @@ package disable import ( + "context" "errors" "fmt" "net/http" "strconv" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -16,7 +17,7 @@ import ( ) type DisableOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Prompter iprompter @@ -32,7 +33,7 @@ type iprompter interface { func NewCmdDisable(f *cmdutil.Factory, runF func(*DisableOptions) error) *cobra.Command { opts := &DisableOptions{ IO: f.IOStreams, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Prompter: f.Prompter, } @@ -56,28 +57,27 @@ func NewCmdDisable(f *cmdutil.Factory, runF func(*DisableOptions) error) *cobra. if runF != nil { return runF(opts) } - return runDisable(opts) + return runDisable(cmd.Context(), opts) }, } return cmd } -func runDisable(opts *DisableOptions) error { - c, err := opts.HttpClient() +func runDisable(ctx context.Context, opts *DisableOptions) error { + repo, err := opts.BaseRepo() if err != nil { - return fmt.Errorf("could not build http client: %w", err) + return err } - client := api.NewClientFromHTTP(c) - repo, err := opts.BaseRepo() + client, err := opts.GitHubREST(repo.RepoHost()) if err != nil { - return err + return fmt.Errorf("could not build client: %w", err) } states := []shared.WorkflowState{shared.Active} workflow, err := shared.ResolveWorkflow( - opts.Prompter, opts.IO, client, repo, opts.Prompt, opts.Selector, states) + ctx, opts.Prompter, opts.IO, client, repo, opts.Prompt, opts.Selector, states) if err != nil { var fae shared.FilteredAllError if errors.As(err, &fae) { @@ -90,8 +90,11 @@ func runDisable(opts *DisableOptions) error { if err != nil { return err } - err = client.REST(repo.RepoHost(), "PUT", path.String(), nil, nil) + req, err := client.NewRequest(ctx, http.MethodPut, path.String(), nil) if err != nil { + return err + } + if _, err := client.Do(req, nil); err != nil { return fmt.Errorf("failed to disable workflow: %w", err) } diff --git a/pkg/cmd/workflow/disable/disable_test.go b/pkg/cmd/workflow/disable/disable_test.go index d8ba3fcd9ac..1b105efffca 100644 --- a/pkg/cmd/workflow/disable/disable_test.go +++ b/pkg/cmd/workflow/disable/disable_test.go @@ -3,7 +3,6 @@ package disable import ( "bytes" "io" - "net/http" "testing" "github.com/cli/cli/v2/internal/ghrepo" @@ -260,9 +259,7 @@ func TestDisableRun(t *testing.T) { for _, tt := range tests { reg := &httpmock.Registry{} tt.httpStubs(reg) - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, _ := iostreams.Test() ios.SetStdoutTTY(tt.tty) @@ -279,7 +276,7 @@ func TestDisableRun(t *testing.T) { tt.promptStubs(pm) } - err := runDisable(tt.opts) + err := runDisable(t.Context(), tt.opts) if tt.wantErr { assert.Error(t, err) assert.Equal(t, tt.wantErrOut, err.Error()) diff --git a/pkg/cmd/workflow/enable/enable.go b/pkg/cmd/workflow/enable/enable.go index 1fc6eb755df..fd4a1ea8619 100644 --- a/pkg/cmd/workflow/enable/enable.go +++ b/pkg/cmd/workflow/enable/enable.go @@ -1,13 +1,14 @@ package enable import ( + "context" "errors" "fmt" "net/http" "strconv" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -16,7 +17,7 @@ import ( ) type EnableOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Prompter iprompter @@ -32,7 +33,7 @@ type iprompter interface { func NewCmdEnable(f *cmdutil.Factory, runF func(*EnableOptions) error) *cobra.Command { opts := &EnableOptions{ IO: f.IOStreams, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Prompter: f.Prompter, } @@ -56,27 +57,26 @@ func NewCmdEnable(f *cmdutil.Factory, runF func(*EnableOptions) error) *cobra.Co if runF != nil { return runF(opts) } - return runEnable(opts) + return runEnable(cmd.Context(), opts) }, } return cmd } -func runEnable(opts *EnableOptions) error { - c, err := opts.HttpClient() +func runEnable(ctx context.Context, opts *EnableOptions) error { + repo, err := opts.BaseRepo() if err != nil { - return fmt.Errorf("could not build http client: %w", err) + return err } - client := api.NewClientFromHTTP(c) - repo, err := opts.BaseRepo() + client, err := opts.GitHubREST(repo.RepoHost()) if err != nil { - return err + return fmt.Errorf("could not build client: %w", err) } states := []shared.WorkflowState{shared.DisabledManually, shared.DisabledInactivity} - workflow, err := shared.ResolveWorkflow(opts.Prompter, + workflow, err := shared.ResolveWorkflow(ctx, opts.Prompter, opts.IO, client, repo, opts.Prompt, opts.Selector, states) if err != nil { var fae shared.FilteredAllError @@ -90,8 +90,11 @@ func runEnable(opts *EnableOptions) error { if err != nil { return err } - err = client.REST(repo.RepoHost(), "PUT", path.String(), nil, nil) + req, err := client.NewRequest(ctx, http.MethodPut, path.String(), nil) if err != nil { + return err + } + if _, err := client.Do(req, nil); err != nil { return fmt.Errorf("failed to enable workflow: %w", err) } diff --git a/pkg/cmd/workflow/enable/enable_test.go b/pkg/cmd/workflow/enable/enable_test.go index 9059650875b..a0a7c82f030 100644 --- a/pkg/cmd/workflow/enable/enable_test.go +++ b/pkg/cmd/workflow/enable/enable_test.go @@ -3,7 +3,6 @@ package enable import ( "bytes" "io" - "net/http" "testing" "github.com/cli/cli/v2/internal/ghrepo" @@ -300,9 +299,7 @@ func TestEnableRun(t *testing.T) { for _, tt := range tests { reg := &httpmock.Registry{} tt.httpStubs(reg) - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, _ := iostreams.Test() ios.SetStdoutTTY(tt.tty) @@ -319,7 +316,7 @@ func TestEnableRun(t *testing.T) { tt.promptStubs(pm) } - err := runEnable(tt.opts) + err := runEnable(t.Context(), tt.opts) if tt.wantErr { assert.Error(t, err) assert.Equal(t, tt.wantErrOut, err.Error()) diff --git a/pkg/cmd/workflow/list/list.go b/pkg/cmd/workflow/list/list.go index efedc731e1d..87a92d16698 100644 --- a/pkg/cmd/workflow/list/list.go +++ b/pkg/cmd/workflow/list/list.go @@ -1,11 +1,11 @@ package list import ( + "context" "fmt" - "net/http" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmd/workflow/shared" "github.com/cli/cli/v2/pkg/cmdutil" @@ -17,7 +17,7 @@ const defaultLimit = 50 type ListOptions struct { IO *iostreams.IOStreams - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) BaseRepo func() (ghrepo.Interface, error) Exporter cmdutil.Exporter @@ -35,7 +35,7 @@ var workflowFields = []string{ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ IO: f.IOStreams, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -56,7 +56,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return runF(opts) } - return listRun(opts) + return listRun(cmd.Context(), opts) }, } @@ -66,20 +66,19 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman return cmd } -func listRun(opts *ListOptions) error { +func listRun(ctx context.Context, opts *ListOptions) error { repo, err := opts.BaseRepo() if err != nil { return err } - httpClient, err := opts.HttpClient() + client, err := opts.GitHubREST(repo.RepoHost()) if err != nil { - return fmt.Errorf("could not create http client: %w", err) + return fmt.Errorf("could not create client: %w", err) } - client := api.NewClientFromHTTP(httpClient) opts.IO.StartProgressIndicator() - workflows, err := shared.GetWorkflows(client, repo, opts.Limit) + workflows, err := shared.GetWorkflows(ctx, client, repo, opts.Limit) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("could not get workflows: %w", err) diff --git a/pkg/cmd/workflow/list/list_test.go b/pkg/cmd/workflow/list/list_test.go index 4a2b33a3d84..337eabdebf8 100644 --- a/pkg/cmd/workflow/list/list_test.go +++ b/pkg/cmd/workflow/list/list_test.go @@ -4,7 +4,6 @@ import ( "bytes" "fmt" "io" - "net/http" "testing" "github.com/cli/cli/v2/internal/ghrepo" @@ -203,9 +202,7 @@ func TestListRun(t *testing.T) { tt.stubs(reg) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.tty) @@ -215,7 +212,7 @@ func TestListRun(t *testing.T) { return ghrepo.FromFullName("OWNER/REPO") } - err := listRun(tt.opts) + err := listRun(t.Context(), tt.opts) if tt.wantErr { assert.Error(t, err) } else { diff --git a/pkg/cmd/workflow/run/run.go b/pkg/cmd/workflow/run/run.go index 70ceecb9e41..994b97828cf 100644 --- a/pkg/cmd/workflow/run/run.go +++ b/pkg/cmd/workflow/run/run.go @@ -2,6 +2,7 @@ package run import ( "bytes" + "context" "encoding/json" "errors" "fmt" @@ -135,7 +136,7 @@ func NewCmdRun(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command return runF(opts) } - return runRun(opts) + return runRun(cmd.Context(), opts) }, } cmd.Flags().StringVarP(&opts.Ref, "ref", "r", "", "Branch or tag name which contains the version of the workflow file you'd like to run") @@ -258,7 +259,7 @@ func collectInputs(p iprompter, yamlContent []byte) (map[string]string, error) { return providedInputs, nil } -func runRun(opts *RunOptions) error { +func runRun(ctx context.Context, opts *RunOptions) error { c, err := opts.HttpClient() if err != nil { return fmt.Errorf("could not build http client: %w", err) @@ -270,13 +271,18 @@ func runRun(opts *RunOptions) error { return err } + restClient, err := opts.GitHubREST(repo.RepoHost()) + if err != nil { + return fmt.Errorf("could not build REST client: %w", err) + } + if opts.Detector == nil { cachedClient := api.NewCachedHTTPClient(c, time.Hour*24) - restClient, err := opts.GitHubREST(repo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) + cachedRESTClient, err := opts.GitHubREST(repo.RepoHost(), githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(time.Hour*24))) if err != nil { return err } - opts.Detector = fd.NewDetector(cachedClient, restClient, repo.RepoHost()) + opts.Detector = fd.NewDetector(cachedClient, cachedRESTClient, repo.RepoHost()) } ref := opts.Ref @@ -289,8 +295,8 @@ func runRun(opts *RunOptions) error { } states := []shared.WorkflowState{shared.Active} - workflow, err := shared.ResolveWorkflow(opts.Prompter, - opts.IO, client, repo, opts.Prompt, opts.Selector, states) + workflow, err := shared.ResolveWorkflow(ctx, opts.Prompter, + opts.IO, restClient, repo, opts.Prompt, opts.Selector, states) if err != nil { var fae shared.FilteredAllError if errors.As(err, &fae) { @@ -312,7 +318,7 @@ func runRun(opts *RunOptions) error { return fmt.Errorf("could not parse provided JSON: %w", err) } } else if opts.Prompt { - yamlContent, err := shared.GetWorkflowContent(client, repo, *workflow, ref) + yamlContent, err := shared.GetWorkflowContent(ctx, restClient, repo, *workflow, ref) if err != nil { return fmt.Errorf("unable to fetch workflow file content: %w", err) } @@ -369,8 +375,11 @@ func runRun(opts *RunOptions) error { // // As a related note, the new REST API version (which will come with breaking // changes) will probably default to return 200 + run details. - err = client.REST(repo.RepoHost(), "POST", path.String(), body, &response) + req, err := restClient.NewRequest(ctx, http.MethodPost, path.String(), body) if err != nil { + return err + } + if _, err := restClient.Do(req, &response); err != nil { return fmt.Errorf("could not create workflow dispatch event: %w", err) } diff --git a/pkg/cmd/workflow/run/run_test.go b/pkg/cmd/workflow/run/run_test.go index 4370130fc8a..1b547f9bb37 100644 --- a/pkg/cmd/workflow/run/run_test.go +++ b/pkg/cmd/workflow/run/run_test.go @@ -1139,7 +1139,7 @@ jobs: tt.promptStubs(pm) } - err := runRun(tt.opts) + err := runRun(t.Context(), tt.opts) if tt.wantErr { assert.Error(t, err) assert.Equal(t, tt.errOut, err.Error()) diff --git a/pkg/cmd/workflow/shared/shared.go b/pkg/cmd/workflow/shared/shared.go index 081773b8bab..278b6d445bc 100644 --- a/pkg/cmd/workflow/shared/shared.go +++ b/pkg/cmd/workflow/shared/shared.go @@ -2,15 +2,16 @@ package shared import ( "bytes" + "context" "encoding/base64" "errors" "fmt" "io" + "net/http" "path" "strconv" "strings" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" @@ -55,7 +56,7 @@ func (w *Workflow) ExportData(fields []string) map[string]interface{} { return cmdutil.StructExportData(w, fields) } -func GetWorkflows(client *api.Client, repo ghrepo.Interface, limit int) ([]Workflow, error) { +func GetWorkflows(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, limit int) ([]Workflow, error) { perPage := limit page := 1 if limit > 100 || limit == 0 { @@ -77,10 +78,13 @@ func GetWorkflows(client *api.Client, repo ghrepo.Interface, limit int) ([]Workf u.SetQuery("per_page", strconv.Itoa(perPage)) u.SetQuery("page", strconv.Itoa(page)) - err = client.REST(repo.RepoHost(), "GET", u.String(), nil, &result) + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } + if _, err := client.Do(req, &result); err != nil { + return nil, err + } for _, workflow := range result.Workflows { workflows = append(workflows, workflow) @@ -129,13 +133,13 @@ func selectWorkflow(p iprompter, workflows []Workflow, promptMsg string, states } // FindWorkflow looks up a workflow either by numeric database ID, file name, or its Name field -func FindWorkflow(client *api.Client, repo ghrepo.Interface, workflowSelector string, states []WorkflowState) ([]Workflow, error) { +func FindWorkflow(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, workflowSelector string, states []WorkflowState) ([]Workflow, error) { if workflowSelector == "" { return nil, errors.New("empty workflow selector") } if _, err := strconv.Atoi(workflowSelector); err == nil || isWorkflowFile(workflowSelector) { - workflow, err := getWorkflowByID(client, repo, workflowSelector) + workflow, err := getWorkflowByID(ctx, client, repo, workflowSelector) if err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { @@ -149,11 +153,11 @@ func FindWorkflow(client *api.Client, repo ghrepo.Interface, workflowSelector st return []Workflow{*workflow}, nil } - return getWorkflowsByName(client, repo, workflowSelector, states) + return getWorkflowsByName(ctx, client, repo, workflowSelector, states) } -func GetWorkflow(client *api.Client, repo ghrepo.Interface, workflowID int64) (*Workflow, error) { - return getWorkflowByID(client, repo, strconv.FormatInt(workflowID, 10)) +func GetWorkflow(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, workflowID int64) (*Workflow, error) { + return getWorkflowByID(ctx, client, repo, strconv.FormatInt(workflowID, 10)) } func isWorkflowFile(f string) bool { @@ -162,22 +166,26 @@ func isWorkflowFile(f string) bool { } // ID can be either a numeric database ID or the workflow file name -func getWorkflowByID(client *api.Client, repo ghrepo.Interface, ID string) (*Workflow, error) { +func getWorkflowByID(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, ID string) (*Workflow, error) { var workflow Workflow path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "actions", "workflows", ID) if err != nil { return nil, err } - if err := client.REST(repo.RepoHost(), "GET", path.String(), nil, &workflow); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(req, &workflow); err != nil { return nil, err } return &workflow, nil } -func getWorkflowsByName(client *api.Client, repo ghrepo.Interface, name string, states []WorkflowState) ([]Workflow, error) { - workflows, err := GetWorkflows(client, repo, 0) +func getWorkflowsByName(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, name string, states []WorkflowState) ([]Workflow, error) { + workflows, err := GetWorkflows(ctx, client, repo, 0) if err != nil { return nil, fmt.Errorf("couldn't fetch workflows for %s: %w", ghrepo.FullName(repo), err) } @@ -198,9 +206,9 @@ func getWorkflowsByName(client *api.Client, repo ghrepo.Interface, name string, return filtered, nil } -func ResolveWorkflow(p iprompter, io *iostreams.IOStreams, client *api.Client, repo ghrepo.Interface, prompt bool, workflowSelector string, states []WorkflowState) (*Workflow, error) { +func ResolveWorkflow(ctx context.Context, p iprompter, io *iostreams.IOStreams, client *githubrest.Client, repo ghrepo.Interface, prompt bool, workflowSelector string, states []WorkflowState) (*Workflow, error) { if prompt { - workflows, err := GetWorkflows(client, repo, 0) + workflows, err := GetWorkflows(ctx, client, repo, 0) if len(workflows) == 0 { err = errors.New("no workflows are enabled") } @@ -217,7 +225,7 @@ func ResolveWorkflow(p iprompter, io *iostreams.IOStreams, client *api.Client, r return selectWorkflow(p, workflows, "Select a workflow", states) } - workflows, err := FindWorkflow(client, repo, workflowSelector, states) + workflows, err := FindWorkflow(ctx, client, repo, workflowSelector, states) if err != nil { return nil, err } @@ -241,7 +249,7 @@ func ResolveWorkflow(p iprompter, io *iostreams.IOStreams, client *api.Client, r return selectWorkflow(p, workflows, "Which workflow do you mean?", states) } -func GetWorkflowContent(client *api.Client, repo ghrepo.Interface, workflow Workflow, ref string) ([]byte, error) { +func GetWorkflowContent(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, workflow Workflow, ref string) ([]byte, error) { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "contents", workflow.Path) if err != nil { return nil, err @@ -255,10 +263,13 @@ func GetWorkflowContent(client *api.Client, repo ghrepo.Interface, workflow Work } var result Result - err = client.REST(repo.RepoHost(), "GET", path.String(), nil, &result) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) if err != nil { return nil, err } + if _, err := client.Do(req, &result); err != nil { + return nil, err + } decoded, err := base64.StdEncoding.DecodeString(result.Content) if err != nil { diff --git a/pkg/cmd/workflow/shared/shared_test.go b/pkg/cmd/workflow/shared/shared_test.go index 644fff256e8..11bacdcde1e 100644 --- a/pkg/cmd/workflow/shared/shared_test.go +++ b/pkg/cmd/workflow/shared/shared_test.go @@ -8,7 +8,6 @@ import ( "strings" "testing" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" @@ -142,9 +141,10 @@ func TestFindWorkflow(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) - workflow, err := FindWorkflow(client, tt.repo, tt.workflowSelector, tt.states) + workflow, err := FindWorkflow(t.Context(), client, tt.repo, tt.workflowSelector, tt.states) if tt.expectedError != nil { require.Error(t, err) assert.Equal(t, tt.expectedError, err) @@ -170,9 +170,10 @@ func (t *ErrorTransport) RoundTrip(req *http.Request) (*http.Response, error) { func TestFindWorkflow_nonHTTPError(t *testing.T) { t.Run("When the client fails to instantiate, it returns the error", func(t *testing.T) { - client := api.NewClientFromHTTP(&http.Client{Transport: &ErrorTransport{Err: errors.New("non-HTTP error")}}) + client, err := httpmock.RESTClientFunc(&ErrorTransport{Err: errors.New("non-HTTP error")})("github.com") + require.NoError(t, err) repo := ghrepo.New("OWNER", "REPO") - workflow, err := FindWorkflow(client, repo, "1", nil) + workflow, err := FindWorkflow(t.Context(), client, repo, "1", nil) require.Error(t, err) assert.ErrorContains(t, err, "non-HTTP error") @@ -282,9 +283,10 @@ func Test_getWorkflowsByName_filtering(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) - workflows, err := getWorkflowsByName(client, tt.repo, tt.workflowName, tt.states) + workflows, err := getWorkflowsByName(t.Context(), client, tt.repo, tt.workflowName, tt.states) if tt.expectedErrorMsg != "" { require.Error(t, err) assert.ErrorContains(t, err, tt.expectedErrorMsg) @@ -387,9 +389,10 @@ func TestGetWorkflows(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) - workflows, err := GetWorkflows(client, tt.repo, tt.limit) + workflows, err := GetWorkflows(t.Context(), client, tt.repo, tt.limit) if tt.expectedError != nil { require.Error(t, err) assert.Equal(t, tt.expectedError, err) diff --git a/pkg/cmd/workflow/view/view.go b/pkg/cmd/workflow/view/view.go index 2713c727543..707df475fc9 100644 --- a/pkg/cmd/workflow/view/view.go +++ b/pkg/cmd/workflow/view/view.go @@ -1,6 +1,7 @@ package view import ( + "context" "fmt" "net/http" "net/url" @@ -24,6 +25,7 @@ import ( type ViewOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser @@ -47,6 +49,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman opts := &ViewOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Browser: f.Browser, Prompter: f.Prompter, now: time.Now(), @@ -84,7 +87,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return runView(opts) + return runView(cmd.Context(), opts) }, } @@ -97,7 +100,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman return cmd } -func runView(opts *ViewOptions) error { +func runView(ctx context.Context, opts *ViewOptions) error { c, err := opts.HttpClient() if err != nil { return fmt.Errorf("could not build http client: %w", err) @@ -109,9 +112,14 @@ func runView(opts *ViewOptions) error { return err } + restClient, err := opts.GitHubREST(repo.RepoHost()) + if err != nil { + return fmt.Errorf("could not build REST client: %w", err) + } + var workflow *shared.Workflow states := []shared.WorkflowState{shared.Active} - workflow, err = shared.ResolveWorkflow(opts.Prompter, opts.IO, client, repo, opts.Prompt, opts.Selector, states) + workflow, err = shared.ResolveWorkflow(ctx, opts.Prompter, opts.IO, restClient, repo, opts.Prompt, opts.Selector, states) if err != nil { return err } @@ -146,9 +154,9 @@ func runView(opts *ViewOptions) error { } if opts.YAML { - err = viewWorkflowContent(opts, client, repo, workflow, opts.Ref) + err = viewWorkflowContent(ctx, opts, restClient, repo, workflow, opts.Ref) } else { - err = viewWorkflowInfo(opts, client, repo, workflow) + err = viewWorkflowInfo(ctx, opts, restClient, repo, workflow) } if err != nil { return err @@ -157,8 +165,8 @@ func runView(opts *ViewOptions) error { return nil } -func viewWorkflowContent(opts *ViewOptions, client *api.Client, repo ghrepo.Interface, workflow *shared.Workflow, ref string) error { - yamlBytes, err := shared.GetWorkflowContent(client, repo, *workflow, ref) +func viewWorkflowContent(ctx context.Context, opts *ViewOptions, client *githubrest.Client, repo ghrepo.Interface, workflow *shared.Workflow, ref string) error { + yamlBytes, err := shared.GetWorkflowContent(ctx, client, repo, *workflow, ref) if err != nil { if s, ok := err.(*githubrest.ErrorResponse); ok && s.StatusCode == 404 { if ref != "" { @@ -203,8 +211,8 @@ func viewWorkflowContent(opts *ViewOptions, client *api.Client, repo ghrepo.Inte return nil } -func viewWorkflowInfo(opts *ViewOptions, client *api.Client, repo ghrepo.Interface, workflow *shared.Workflow) error { - wr, err := runShared.GetRuns(client, repo, &runShared.FilterOptions{ +func viewWorkflowInfo(ctx context.Context, opts *ViewOptions, client *githubrest.Client, repo ghrepo.Interface, workflow *shared.Workflow) error { + wr, err := runShared.GetRuns(ctx, client, repo, &runShared.FilterOptions{ WorkflowID: workflow.ID, WorkflowName: workflow.Name, }, 5) diff --git a/pkg/cmd/workflow/view/view_test.go b/pkg/cmd/workflow/view/view_test.go index db36797666b..fdf083fdff6 100644 --- a/pkg/cmd/workflow/view/view_test.go +++ b/pkg/cmd/workflow/view/view_test.go @@ -404,6 +404,7 @@ func TestViewRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, _ := iostreams.Test() ios.SetStdoutTTY(tt.tty) @@ -418,7 +419,7 @@ func TestViewRun(t *testing.T) { tt.opts.Browser = browser t.Run(tt.name, func(t *testing.T) { - err := runView(tt.opts) + err := runView(t.Context(), tt.opts) if tt.wantErr { assert.Error(t, err) assert.Equal(t, tt.wantErrOut, err.Error()) From 21ba36d16763e755ccd8106cac91c7399fef6ff2 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 16:34:03 +0200 Subject: [PATCH 17/21] Route repo, pr, skills and extension commands through githubrest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- api/queries_pr.go | 10 +- api/queries_pr_review.go | 95 ---- api/queries_pr_test.go | 47 -- api/queries_repo.go | 181 ------- api/queries_repo_test.go | 441 ----------------- internal/skills/discovery/discovery.go | 154 ++++-- internal/skills/discovery/discovery_test.go | 52 +- internal/skills/installer/installer.go | 16 +- internal/skills/installer/installer_test.go | 17 +- pkg/cmd/extension/browse/browse.go | 7 +- pkg/cmd/extension/browse/browse_test.go | 9 +- pkg/cmd/extension/browse/rg.go | 19 +- pkg/cmd/extension/command.go | 2 +- pkg/cmd/extension/extension.go | 10 +- pkg/cmd/extension/http.go | 49 +- pkg/cmd/extension/http_test.go | 59 ++- pkg/cmd/extension/manager.go | 30 +- pkg/cmd/pr/close/close.go | 9 +- pkg/cmd/pr/close/close_test.go | 1 + pkg/cmd/pr/create/create.go | 7 +- pkg/cmd/pr/edit/edit.go | 26 +- pkg/cmd/pr/edit/edit_test.go | 14 +- pkg/cmd/pr/edit/reviewers_rest.go | 114 +++++ pkg/cmd/pr/merge/merge.go | 12 +- pkg/cmd/pr/merge/merge_test.go | 5 + pkg/cmd/pr/shared/branch.go | 25 + pkg/cmd/pr/shared/branch_test.go | 58 +++ pkg/cmd/repo/autolink/create/create.go | 16 +- pkg/cmd/repo/autolink/create/create_test.go | 5 +- pkg/cmd/repo/autolink/create/http.go | 19 +- pkg/cmd/repo/autolink/create/http_test.go | 5 +- pkg/cmd/repo/autolink/delete/delete.go | 20 +- pkg/cmd/repo/autolink/delete/delete_test.go | 7 +- pkg/cmd/repo/autolink/delete/http.go | 19 +- pkg/cmd/repo/autolink/delete/http_test.go | 5 +- pkg/cmd/repo/autolink/list/http.go | 19 +- pkg/cmd/repo/autolink/list/http_test.go | 5 +- pkg/cmd/repo/autolink/list/list.go | 15 +- pkg/cmd/repo/autolink/list/list_test.go | 5 +- pkg/cmd/repo/autolink/view/http.go | 19 +- pkg/cmd/repo/autolink/view/http_test.go | 5 +- pkg/cmd/repo/autolink/view/view.go | 16 +- pkg/cmd/repo/autolink/view/view_test.go | 5 +- pkg/cmd/repo/create/create.go | 72 +-- pkg/cmd/repo/create/create_test.go | 4 +- pkg/cmd/repo/create/http.go | 32 +- pkg/cmd/repo/create/http_test.go | 6 +- pkg/cmd/repo/credits/credits.go | 29 +- pkg/cmd/repo/deploy-key/add/add.go | 23 +- pkg/cmd/repo/deploy-key/add/add_test.go | 13 +- pkg/cmd/repo/deploy-key/add/http.go | 15 +- pkg/cmd/repo/deploy-key/delete/delete.go | 17 +- pkg/cmd/repo/deploy-key/delete/delete_test.go | 17 +- pkg/cmd/repo/deploy-key/delete/http.go | 15 +- pkg/cmd/repo/deploy-key/list/http.go | 13 +- pkg/cmd/repo/deploy-key/list/list.go | 17 +- pkg/cmd/repo/deploy-key/list/list_test.go | 13 +- pkg/cmd/repo/edit/edit.go | 57 +-- pkg/cmd/repo/fork/fork.go | 20 +- pkg/cmd/repo/fork/fork_test.go | 24 +- pkg/cmd/repo/gitignore/list/list.go | 26 +- pkg/cmd/repo/gitignore/list/list_test.go | 8 +- pkg/cmd/repo/gitignore/view/view.go | 24 +- pkg/cmd/repo/gitignore/view/view_test.go | 8 +- pkg/cmd/repo/license/list/list.go | 25 +- pkg/cmd/repo/license/list/list_test.go | 8 +- pkg/cmd/repo/license/view/view.go | 24 +- pkg/cmd/repo/license/view/view_test.go | 8 +- pkg/cmd/repo/read-file/http.go | 48 +- pkg/cmd/repo/read-file/read_file.go | 29 +- pkg/cmd/repo/read-file/read_file_test.go | 7 +- pkg/cmd/repo/rename/rename.go | 24 +- pkg/cmd/repo/rename/rename_test.go | 14 +- pkg/cmd/repo/shared/http.go | 217 +++++++++ pkg/cmd/repo/shared/http_test.go | 444 ++++++++++++++++++ pkg/cmd/repo/sync/http.go | 27 +- pkg/cmd/repo/sync/sync.go | 30 +- pkg/cmd/repo/sync/sync_test.go | 13 +- pkg/cmd/repo/view/http.go | 10 +- pkg/cmd/repo/view/view.go | 14 +- pkg/cmd/repo/view/view_test.go | 27 +- pkg/cmd/skills/install/install.go | 54 ++- pkg/cmd/skills/install/install_test.go | 115 +++-- pkg/cmd/skills/preview/preview.go | 43 +- pkg/cmd/skills/preview/preview_test.go | 59 ++- pkg/cmd/skills/publish/publish.go | 137 ++++-- pkg/cmd/skills/publish/publish_test.go | 45 +- pkg/cmd/skills/search/search.go | 74 +-- pkg/cmd/skills/search/search_test.go | 15 +- pkg/cmd/skills/update/update.go | 49 +- pkg/cmd/skills/update/update_test.go | 241 ++++------ 91 files changed, 2130 insertions(+), 1778 deletions(-) create mode 100644 pkg/cmd/pr/edit/reviewers_rest.go create mode 100644 pkg/cmd/pr/shared/branch.go create mode 100644 pkg/cmd/pr/shared/branch_test.go create mode 100644 pkg/cmd/repo/shared/http.go create mode 100644 pkg/cmd/repo/shared/http_test.go diff --git a/api/queries_pr.go b/api/queries_pr.go index 10994958ace..bf16677c8ff 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -6,7 +6,7 @@ import ( "time" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/safeurl" + "github.com/shurcooL/githubv4" ) @@ -844,14 +844,6 @@ func ConvertPullRequestToDraft(client *Client, repo ghrepo.Interface, pr *PullRe return client.Mutate(repo.RepoHost(), "ConvertPullRequestToDraft", &mutation, variables) } -func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) error { - path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "git", "refs", fmt.Sprintf("heads/%s", branch)) - if err != nil { - return err - } - return client.REST(repo.RepoHost(), "DELETE", path.String(), nil, nil) -} - type RefComparison struct { AheadBy int BehindBy int diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index 1526758cd9a..c2fb41c8996 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -1,15 +1,10 @@ package api import ( - "bytes" - "encoding/json" "fmt" - "strconv" - "strings" "time" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/safeurl" "github.com/shurcooL/githubv4" ) @@ -273,82 +268,6 @@ func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *Pu return client.Mutate(repo.RepoHost(), "PullRequestReviewAdd", &mutation, variables) } -// AddPullRequestReviews adds the given user and team reviewers to a pull request using the REST API. -// Team identifiers can be in "org/slug" format. -func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { - if len(users) == 0 && len(teams) == 0 { - return nil - } - - // The API requires empty arrays instead of null values - if users == nil { - users = []string{} - } - - path, err := safeurl.JoinPath( - "repos", - repo.RepoOwner(), - repo.RepoName(), - "pulls", - strconv.Itoa(prNumber), - "requested_reviewers", - ) - if err != nil { - return err - } - body := struct { - Reviewers []string `json:"reviewers"` - TeamReviewers []string `json:"team_reviewers"` - }{ - Reviewers: users, - TeamReviewers: extractTeamSlugs(teams), - } - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(body); err != nil { - return err - } - // The endpoint responds with the updated pull request object; we don't need it here. - return client.REST(repo.RepoHost(), "POST", path.String(), buf, nil) -} - -// RemovePullRequestReviews removes requested reviewers from a pull request using the REST API. -// Team identifiers can be in "org/slug" format. -func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { - if len(users) == 0 && len(teams) == 0 { - return nil - } - - // The API requires empty arrays instead of null values - if users == nil { - users = []string{} - } - - path, err := safeurl.JoinPath( - "repos", - repo.RepoOwner(), - repo.RepoName(), - "pulls", - strconv.Itoa(prNumber), - "requested_reviewers", - ) - if err != nil { - return err - } - body := struct { - Reviewers []string `json:"reviewers"` - TeamReviewers []string `json:"team_reviewers"` - }{ - Reviewers: users, - TeamReviewers: extractTeamSlugs(teams), - } - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(body); err != nil { - return err - } - // The endpoint responds with the updated pull request object; we don't need it here. - return client.REST(repo.RepoHost(), "DELETE", path.String(), buf, nil) -} - // RequestReviewsByLogin sets requested reviewers on a pull request using the GraphQL mutation. // This mutation replaces existing reviewers with the provided set unless union is true. // Only available on github.com, not GHES. @@ -686,20 +605,6 @@ func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query return candidates, moreResults, nil } -// extractTeamSlugs extracts just the slug portion from team identifiers. -// Team identifiers can be in "org/slug" format; this returns just the slug. -func extractTeamSlugs(teams []string) []string { - slugs := make([]string, 0, len(teams)) - for _, t := range teams { - if t == "" { - continue - } - s := strings.SplitN(t, "/", 2) - slugs = append(slugs, s[len(s)-1]) - } - return slugs -} - // toGitHubV4Strings converts a string slice to a githubv4.String slice, // optionally appending a suffix to each element. func toGitHubV4Strings(strs []string, suffix string) []githubv4.String { diff --git a/api/queries_pr_test.go b/api/queries_pr_test.go index cf7e7b04b81..6cc5ebbfe53 100644 --- a/api/queries_pr_test.go +++ b/api/queries_pr_test.go @@ -11,53 +11,6 @@ import ( "github.com/stretchr/testify/assert" ) -func TestBranchDeleteRemote(t *testing.T) { - var tests = []struct { - name string - branch string - httpStubs func(*httpmock.Registry) - expectError bool - }{ - { - name: "success", - branch: "owner/branch#123", - httpStubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Fowner%2Fbranch%23123"), - httpmock.StatusStringResponse(204, "")) - }, - expectError: false, - }, - { - name: "error", - branch: "my-branch", - httpStubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Fmy-branch"), - httpmock.StatusStringResponse(500, `{"message": "oh no"}`)) - }, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - http := &httpmock.Registry{} - if tt.httpStubs != nil { - tt.httpStubs(http) - } - - client := newTestClient(http) - repo, _ := ghrepo.FromFullName("OWNER/REPO") - - err := BranchDeleteRemote(client, repo, tt.branch) - if (err != nil) != tt.expectError { - t.Fatalf("unexpected result: %v", err) - } - }) - } -} - func Test_Logins(t *testing.T) { rr := ReviewRequests{} var tests = []struct { diff --git a/api/queries_repo.go b/api/queries_repo.go index 3e0b75648cb..c060f902ae7 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -6,7 +6,6 @@ import ( "encoding/json" "errors" "fmt" - "io" "net/http" "sort" "strings" @@ -575,101 +574,6 @@ func InitRepoHostname(repo *Repository, hostname string) *Repository { return repo } -// RepositoryV3 is the repository result from GitHub API v3 -type repositoryV3 struct { - NodeID string `json:"node_id"` - Name string - CreatedAt time.Time `json:"created_at"` - Owner struct { - Login string - } - Private bool - HTMLUrl string `json:"html_url"` - Parent *repositoryV3 -} - -// ForkRepo forks the repository on GitHub and returns the new repository -func ForkRepo(client *Client, repo ghrepo.Interface, org, newName string, defaultBranchOnly bool) (*Repository, error) { - path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "forks") - if err != nil { - return nil, err - } - - params := map[string]interface{}{} - if org != "" { - params["organization"] = org - } - if newName != "" { - params["name"] = newName - } - if defaultBranchOnly { - params["default_branch_only"] = true - } - - body := &bytes.Buffer{} - enc := json.NewEncoder(body) - if err := enc.Encode(params); err != nil { - return nil, err - } - - result := repositoryV3{} - err = client.REST(repo.RepoHost(), "POST", path.String(), body, &result) - if err != nil { - return nil, err - } - - newRepo := &Repository{ - ID: result.NodeID, - Name: result.Name, - CreatedAt: result.CreatedAt, - Owner: RepositoryOwner{ - Login: result.Owner.Login, - }, - ViewerPermission: "WRITE", - hostname: repo.RepoHost(), - } - - // The GitHub API will happily return a HTTP 200 when attempting to fork own repo even though no forking - // actually took place. Ensure that we raise an error instead. - if ghrepo.IsSame(repo, newRepo) { - return newRepo, fmt.Errorf("%s cannot be forked. A single user account cannot own both a parent and fork.", ghrepo.FullName(repo)) - } - - return newRepo, nil -} - -// RenameRepo renames the repository on GitHub and returns the renamed repository -func RenameRepo(client *Client, repo ghrepo.Interface, newRepoName string) (*Repository, error) { - input := map[string]string{"name": newRepoName} - body := &bytes.Buffer{} - enc := json.NewEncoder(body) - if err := enc.Encode(input); err != nil { - return nil, err - } - - path, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName()) - if err != nil { - return nil, err - } - - result := repositoryV3{} - err = client.REST(repo.RepoHost(), "PATCH", path.String(), body, &result) - if err != nil { - return nil, err - } - - return &Repository{ - ID: result.NodeID, - Name: result.Name, - CreatedAt: result.CreatedAt, - Owner: RepositoryOwner{ - Login: result.Owner.Login, - }, - ViewerPermission: "WRITE", - hostname: repo.RepoHost(), - }, nil -} - func LastCommit(client *Client, repo ghrepo.Interface) (*Commit, error) { var responseData struct { Repository struct { @@ -1613,27 +1517,6 @@ func v2Projects(client *Client, repo ghrepo.Interface) ([]ProjectV2, error) { return projectsV2, nil } -func CreateRepoTransformToV4(apiClient *Client, hostname string, method string, path safeurl.SafeURL, body io.Reader) (*Repository, error) { - var responsev3 repositoryV3 - err := apiClient.REST(hostname, method, path.String(), body, &responsev3) - - if err != nil { - return nil, err - } - - return &Repository{ - Name: responsev3.Name, - CreatedAt: responsev3.CreatedAt, - Owner: RepositoryOwner{ - Login: responsev3.Owner.Login, - }, - ID: responsev3.NodeID, - hostname: hostname, - URL: responsev3.HTMLUrl, - IsPrivate: responsev3.Private, - }, nil -} - // MapReposToIDs retrieves a set of IDs for the given set of repositories. // This is similar logic to RepoNetwork, but only fetches databaseId and does not // discover parent repositories. @@ -1692,67 +1575,3 @@ func RepoExists(client *Client, repo ghrepo.Interface) (bool, error) { return false, ghAPI.HandleHTTPError(resp) } } - -// RepoLicenses fetches available repository licenses. -// It uses API v3 because licenses are not supported by GraphQL. -func RepoLicenses(httpClient *http.Client, hostname string) ([]License, error) { - var licenses []License - client := NewClientFromHTTP(httpClient) - path, err := safeurl.JoinPath("licenses") - if err != nil { - return nil, err - } - err = client.REST(hostname, "GET", path.String(), nil, &licenses) - if err != nil { - return nil, err - } - return licenses, nil -} - -// RepoLicense fetches an available repository license. -// It uses API v3 because licenses are not supported by GraphQL. -func RepoLicense(httpClient *http.Client, hostname string, licenseName string) (*License, error) { - var license License - client := NewClientFromHTTP(httpClient) - path, err := safeurl.JoinPath("licenses", licenseName) - if err != nil { - return nil, err - } - err = client.REST(hostname, "GET", path.String(), nil, &license) - if err != nil { - return nil, err - } - return &license, nil -} - -// RepoGitIgnoreTemplates fetches available repository gitignore templates. -// It uses API v3 here because gitignore template isn't supported by GraphQL. -func RepoGitIgnoreTemplates(httpClient *http.Client, hostname string) ([]string, error) { - var gitIgnoreTemplates []string - client := NewClientFromHTTP(httpClient) - path, err := safeurl.JoinPath("gitignore", "templates") - if err != nil { - return nil, err - } - err = client.REST(hostname, "GET", path.String(), nil, &gitIgnoreTemplates) - if err != nil { - return nil, err - } - return gitIgnoreTemplates, nil -} - -// RepoGitIgnoreTemplate fetches an available repository gitignore template. -// It uses API v3 here because gitignore template isn't supported by GraphQL. -func RepoGitIgnoreTemplate(httpClient *http.Client, hostname string, gitIgnoreTemplateName string) (*GitIgnore, error) { - var gitIgnoreTemplate GitIgnore - client := NewClientFromHTTP(httpClient) - path, err := safeurl.JoinPath("gitignore", "templates", gitIgnoreTemplateName) - if err != nil { - return nil, err - } - err = client.REST(hostname, "GET", path.String(), nil, &gitIgnoreTemplate) - if err != nil { - return nil, err - } - return &gitIgnoreTemplate, nil -} diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 4fe7074f187..9daaa26dec2 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -8,7 +8,6 @@ import ( "strings" "testing" - "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/httpmock" @@ -822,443 +821,3 @@ func TestRepoExists(t *testing.T) { }) } } - -func TestForkRepoReturnsErrorWhenForkIsNotPossible(t *testing.T) { - // Given our API returns 202 with a Fork that is the same as - // the repo we provided - repoName := "test-repo" - ownerLogin := "test-owner" - stubbedForkResponse := repositoryV3{ - Name: repoName, - Owner: struct{ Login string }{ - Login: ownerLogin, - }, - } - - reg := &httpmock.Registry{} - reg.Register( - httpmock.REST("POST", fmt.Sprintf("repos/%s/%s/forks", ownerLogin, repoName)), - httpmock.StatusJSONResponse(202, stubbedForkResponse), - ) - - client := newTestClient(reg) - - // When we fork the repo - _, err := ForkRepo(client, ghrepo.New(ownerLogin, repoName), ownerLogin, "", false) - - // Then it provides a useful error message - require.Equal(t, fmt.Errorf("%s/%s cannot be forked. A single user account cannot own both a parent and fork.", ownerLogin, repoName), err) -} - -func TestListLicenseTemplatesReturnsLicenses(t *testing.T) { - hostname := "api.github.com" - httpStubs := func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", "licenses"), - httpmock.StringResponse(`[ - { - "key": "mit", - "name": "MIT License", - "spdx_id": "MIT", - "url": "https://api.github.com/licenses/mit", - "node_id": "MDc6TGljZW5zZW1pdA==" - }, - { - "key": "lgpl-3.0", - "name": "GNU Lesser General Public License v3.0", - "spdx_id": "LGPL-3.0", - "url": "https://api.github.com/licenses/lgpl-3.0", - "node_id": "MDc6TGljZW5zZW1pdA==" - }, - { - "key": "mpl-2.0", - "name": "Mozilla Public License 2.0", - "spdx_id": "MPL-2.0", - "url": "https://api.github.com/licenses/mpl-2.0", - "node_id": "MDc6TGljZW5zZW1pdA==" - }, - { - "key": "agpl-3.0", - "name": "GNU Affero General Public License v3.0", - "spdx_id": "AGPL-3.0", - "url": "https://api.github.com/licenses/agpl-3.0", - "node_id": "MDc6TGljZW5zZW1pdA==" - }, - { - "key": "unlicense", - "name": "The Unlicense", - "spdx_id": "Unlicense", - "url": "https://api.github.com/licenses/unlicense", - "node_id": "MDc6TGljZW5zZW1pdA==" - }, - { - "key": "apache-2.0", - "name": "Apache License 2.0", - "spdx_id": "Apache-2.0", - "url": "https://api.github.com/licenses/apache-2.0", - "node_id": "MDc6TGljZW5zZW1pdA==" - }, - { - "key": "gpl-3.0", - "name": "GNU General Public License v3.0", - "spdx_id": "GPL-3.0", - "url": "https://api.github.com/licenses/gpl-3.0", - "node_id": "MDc6TGljZW5zZW1pdA==" - } - ]`, - )) - } - wantLicenses := []License{ - { - Key: "mit", - Name: "MIT License", - SPDXID: "MIT", - URL: "https://api.github.com/licenses/mit", - NodeID: "MDc6TGljZW5zZW1pdA==", - HTMLURL: "", - Description: "", - Implementation: "", - Permissions: nil, - Conditions: nil, - Limitations: nil, - Body: "", - }, - { - Key: "lgpl-3.0", - Name: "GNU Lesser General Public License v3.0", - SPDXID: "LGPL-3.0", - URL: "https://api.github.com/licenses/lgpl-3.0", - NodeID: "MDc6TGljZW5zZW1pdA==", - HTMLURL: "", - Description: "", - Implementation: "", - Permissions: nil, - Conditions: nil, - Limitations: nil, - Body: "", - }, - { - Key: "mpl-2.0", - Name: "Mozilla Public License 2.0", - SPDXID: "MPL-2.0", - URL: "https://api.github.com/licenses/mpl-2.0", - NodeID: "MDc6TGljZW5zZW1pdA==", - HTMLURL: "", - Description: "", - Implementation: "", - Permissions: nil, - Conditions: nil, - Limitations: nil, - Body: "", - }, - { - Key: "agpl-3.0", - Name: "GNU Affero General Public License v3.0", - SPDXID: "AGPL-3.0", - URL: "https://api.github.com/licenses/agpl-3.0", - NodeID: "MDc6TGljZW5zZW1pdA==", - HTMLURL: "", - Description: "", - Implementation: "", - Permissions: nil, - Conditions: nil, - Limitations: nil, - Body: "", - }, - { - Key: "unlicense", - Name: "The Unlicense", - SPDXID: "Unlicense", - URL: "https://api.github.com/licenses/unlicense", - NodeID: "MDc6TGljZW5zZW1pdA==", - HTMLURL: "", - Description: "", - Implementation: "", - Permissions: nil, - Conditions: nil, - Limitations: nil, - Body: "", - }, - { - Key: "apache-2.0", - Name: "Apache License 2.0", - SPDXID: "Apache-2.0", - URL: "https://api.github.com/licenses/apache-2.0", - NodeID: "MDc6TGljZW5zZW1pdA==", - HTMLURL: "", - Description: "", - Implementation: "", - Permissions: nil, - Conditions: nil, - Limitations: nil, - Body: "", - }, - { - Key: "gpl-3.0", - Name: "GNU General Public License v3.0", - SPDXID: "GPL-3.0", - URL: "https://api.github.com/licenses/gpl-3.0", - NodeID: "MDc6TGljZW5zZW1pdA==", - HTMLURL: "", - Description: "", - Implementation: "", - Permissions: nil, - Conditions: nil, - Limitations: nil, - Body: "", - }, - } - - reg := &httpmock.Registry{} - httpStubs(reg) - - httpClient := func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - client, _ := httpClient() - defer reg.Verify(t) - - gotLicenses, err := RepoLicenses(client, hostname) - - assert.NoError(t, err, "Expected no error while fetching /licenses") - assert.Equal(t, wantLicenses, gotLicenses, "Licenses fetched is not as expected") -} - -func TestLicenseTemplateReturnsLicense(t *testing.T) { - licenseTemplateName := "mit" - hostname := "api.github.com" - httpStubs := func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", fmt.Sprintf("licenses/%v", licenseTemplateName)), - httpmock.StringResponse(`{ - "key": "mit", - "name": "MIT License", - "spdx_id": "MIT", - "url": "https://api.github.com/licenses/mit", - "node_id": "MDc6TGljZW5zZTEz", - "html_url": "http://choosealicense.com/licenses/mit/", - "description": "A short and simple permissive license with conditions only requiring preservation of copyright and license notices. Licensed works, modifications, and larger works may be distributed under different terms and without source code.", - "implementation": "Create a text file (typically named LICENSE or LICENSE.txt) in the root of your source code and copy the text of the license into the file. Replace [year] with the current year and [fullname] with the name (or names) of the copyright holders.", - "permissions": [ - "commercial-use", - "modifications", - "distribution", - "private-use" - ], - "conditions": [ - "include-copyright" - ], - "limitations": [ - "liability", - "warranty" - ], - "body": "MIT License\n\nCopyright (c) [year] [fullname]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", - "featured": true - }`, - )) - } - wantLicense := &License{ - Key: "mit", - Name: "MIT License", - SPDXID: "MIT", - URL: "https://api.github.com/licenses/mit", - NodeID: "MDc6TGljZW5zZTEz", - HTMLURL: "http://choosealicense.com/licenses/mit/", - Description: "A short and simple permissive license with conditions only requiring preservation of copyright and license notices. Licensed works, modifications, and larger works may be distributed under different terms and without source code.", - Implementation: "Create a text file (typically named LICENSE or LICENSE.txt) in the root of your source code and copy the text of the license into the file. Replace [year] with the current year and [fullname] with the name (or names) of the copyright holders.", - Permissions: []string{ - "commercial-use", - "modifications", - "distribution", - "private-use", - }, - Conditions: []string{ - "include-copyright", - }, - Limitations: []string{ - "liability", - "warranty", - }, - Body: "MIT License\n\nCopyright (c) [year] [fullname]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", - Featured: true, - } - - reg := &httpmock.Registry{} - httpStubs(reg) - - httpClient := func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - client, _ := httpClient() - defer reg.Verify(t) - - gotLicenseTemplate, err := RepoLicense(client, hostname, licenseTemplateName) - - assert.NoError(t, err, fmt.Sprintf("Expected no error while fetching /licenses/%v", licenseTemplateName)) - assert.Equal(t, wantLicense, gotLicenseTemplate, fmt.Sprintf("License \"%v\" fetched is not as expected", licenseTemplateName)) -} - -func TestLicenseTemplateReturnsErrorWhenLicenseTemplateNotFound(t *testing.T) { - licenseTemplateName := "invalid-license" - hostname := "api.github.com" - httpStubs := func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", fmt.Sprintf("licenses/%v", licenseTemplateName)), - httpmock.StatusStringResponse(404, heredoc.Doc(` - { - "message": "Not Found", - "documentation_url": "https://docs.github.com/rest/licenses/licenses#get-a-license", - "status": "404" - }`)), - ) - } - - reg := &httpmock.Registry{} - httpStubs(reg) - - httpClient := func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - client, _ := httpClient() - defer reg.Verify(t) - - _, err := RepoLicense(client, hostname, licenseTemplateName) - - assert.Error(t, err, fmt.Sprintf("Expected error while fetching /licenses/%v", licenseTemplateName)) -} - -func TestListGitIgnoreTemplatesReturnsGitIgnoreTemplates(t *testing.T) { - hostname := "api.github.com" - httpStubs := func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", "gitignore/templates"), - httpmock.StringResponse(`[ - "AL", - "Actionscript", - "Ada", - "Agda", - "Android", - "AppEngine", - "AppceleratorTitanium", - "ArchLinuxPackages", - "Autotools", - "Ballerina", - "C", - "C++", - "CFWheels", - "CMake", - "CUDA", - "CakePHP", - "ChefCookbook", - "Clojure", - "CodeIgniter", - "CommonLisp", - "Composer", - "Concrete5", - "Coq", - "CraftCMS", - "D" - ]`, - )) - } - wantGitIgnoreTemplates := []string{ - "AL", - "Actionscript", - "Ada", - "Agda", - "Android", - "AppEngine", - "AppceleratorTitanium", - "ArchLinuxPackages", - "Autotools", - "Ballerina", - "C", - "C++", - "CFWheels", - "CMake", - "CUDA", - "CakePHP", - "ChefCookbook", - "Clojure", - "CodeIgniter", - "CommonLisp", - "Composer", - "Concrete5", - "Coq", - "CraftCMS", - "D", - } - - reg := &httpmock.Registry{} - httpStubs(reg) - - httpClient := func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - client, _ := httpClient() - defer reg.Verify(t) - - gotGitIgnoreTemplates, err := RepoGitIgnoreTemplates(client, hostname) - - assert.NoError(t, err, "Expected no error while fetching /gitignore/templates") - assert.Equal(t, wantGitIgnoreTemplates, gotGitIgnoreTemplates, "GitIgnore templates fetched is not as expected") -} - -func TestGitIgnoreTemplateReturnsGitIgnoreTemplate(t *testing.T) { - gitIgnoreTemplateName := "Go" - httpStubs := func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", fmt.Sprintf("gitignore/templates/%v", gitIgnoreTemplateName)), - httpmock.StringResponse(`{ - "name": "Go", - "source": "# If you prefer the allow list template instead of the deny list, see community template:\n# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore\n#\n# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Test binary, built with go test -c\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependency directories (remove the comment below to include it)\n# vendor/\n\n# Go workspace file\ngo.work\ngo.work.sum\n\n# env file\n.env\n" - }`, - )) - } - wantGitIgnoreTemplate := &GitIgnore{ - Name: "Go", - Source: "# If you prefer the allow list template instead of the deny list, see community template:\n# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore\n#\n# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Test binary, built with go test -c\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependency directories (remove the comment below to include it)\n# vendor/\n\n# Go workspace file\ngo.work\ngo.work.sum\n\n# env file\n.env\n", - } - - reg := &httpmock.Registry{} - httpStubs(reg) - - httpClient := func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - client, _ := httpClient() - defer reg.Verify(t) - - gotGitIgnoreTemplate, err := RepoGitIgnoreTemplate(client, "api.github.com", gitIgnoreTemplateName) - - assert.NoError(t, err, fmt.Sprintf("Expected no error while fetching /gitignore/templates/%v", gitIgnoreTemplateName)) - assert.Equal(t, wantGitIgnoreTemplate, gotGitIgnoreTemplate, fmt.Sprintf("GitIgnore template \"%v\" fetched is not as expected", gitIgnoreTemplateName)) -} - -func TestGitIgnoreTemplateReturnsErrorWhenGitIgnoreTemplateNotFound(t *testing.T) { - gitIgnoreTemplateName := "invalid-gitignore" - httpStubs := func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", fmt.Sprintf("gitignore/templates/%v", gitIgnoreTemplateName)), - httpmock.StatusStringResponse(404, heredoc.Doc(` - { - "message": "Not Found", - "documentation_url": "https://docs.github.com/v3/gitignore", - "status": "404" - }`)), - ) - } - - reg := &httpmock.Registry{} - httpStubs(reg) - - httpClient := func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } - client, _ := httpClient() - defer reg.Verify(t) - - _, err := RepoGitIgnoreTemplate(client, "api.github.com", gitIgnoreTemplateName) - - assert.Error(t, err, fmt.Sprintf("Expected error while fetching /gitignore/templates/%v", gitIgnoreTemplateName)) -} diff --git a/internal/skills/discovery/discovery.go b/internal/skills/discovery/discovery.go index 995ce703a6f..7b1589b7638 100644 --- a/internal/skills/discovery/discovery.go +++ b/internal/skills/discovery/discovery.go @@ -1,6 +1,7 @@ package discovery import ( + "context" "encoding/base64" "errors" "fmt" @@ -15,7 +16,6 @@ import ( "sync" "sync/atomic" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/skills/frontmatter" @@ -189,7 +189,7 @@ func parseRepoVisibility(s string) (RepoVisibility, error) { } // FetchRepoVisibility returns the repository visibility: "public", "private", or "internal". -func FetchRepoVisibility(client *api.Client, host, owner, repo string) (RepoVisibility, error) { +func FetchRepoVisibility(ctx context.Context, client *githubrest.Client, owner, repo string) (RepoVisibility, error) { apiPath, err := safeurl.JoinPath("repos", owner, repo) if err != nil { return "", err @@ -197,7 +197,11 @@ func FetchRepoVisibility(client *api.Client, host, owner, repo string) (RepoVisi var resp struct { Visibility string `json:"visibility"` } - if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return "", err + } + if _, err := client.Do(req, &resp); err != nil { return "", err } return parseRepoVisibility(resp.Visibility) @@ -205,11 +209,11 @@ func FetchRepoVisibility(client *api.Client, host, owner, repo string) (RepoVisi // ResolveRef determines the git ref to use for a given owner/repo. // Priority: explicit version > latest release tag > default branch. -func ResolveRef(client *api.Client, host, owner, repo, version string) (*ResolvedRef, error) { +func ResolveRef(ctx context.Context, client *githubrest.Client, owner, repo, version string) (*ResolvedRef, error) { if version != "" { - return resolveExplicitRef(client, host, owner, repo, version) + return resolveExplicitRef(ctx, client, owner, repo, version) } - ref, err := resolveLatestRelease(client, host, owner, repo) + ref, err := resolveLatestRelease(ctx, client, owner, repo) if err == nil { return ref, nil } @@ -222,7 +226,7 @@ func ResolveRef(client *api.Client, host, owner, repo, version string) (*Resolve if !errors.As(err, &nre) { return nil, err } - return resolveDefaultBranch(client, host, owner, repo) + return resolveDefaultBranch(ctx, client, owner, repo) } // resolveExplicitRef resolves a user-supplied version string. It supports: @@ -233,24 +237,24 @@ func ResolveRef(client *api.Client, host, owner, repo, version string) (*Resolve // When a short name matches both a branch and a tag, the branch wins. // The returned Ref is always a fully qualified ref (refs/heads/* or refs/tags/*) // unless the input resolves to a bare commit SHA. -func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*ResolvedRef, error) { +func resolveExplicitRef(ctx context.Context, client *githubrest.Client, owner, repo, ref string) (*ResolvedRef, error) { // Handle fully-qualified refs: resolve directly without ambiguity. if after, ok := strings.CutPrefix(ref, "refs/tags/"); ok { - return resolveTagRef(client, host, owner, repo, after) + return resolveTagRef(ctx, client, owner, repo, after) } if after, ok := strings.CutPrefix(ref, "refs/heads/"); ok { - return resolveBranchRef(client, host, owner, repo, after) + return resolveBranchRef(ctx, client, owner, repo, after) } // Short name: try branch first, then tag, then commit SHA. // Only fall through on 404 (not found); surface other errors // (403, 500, network) immediately to avoid masking real failures. - if resolved, err := resolveBranchRef(client, host, owner, repo, ref); err == nil { + if resolved, err := resolveBranchRef(ctx, client, owner, repo, ref); err == nil { return resolved, nil } else if !isNotFound(err) { return nil, err } - if resolved, err := resolveTagRef(client, host, owner, repo, ref); err == nil { + if resolved, err := resolveTagRef(ctx, client, owner, repo, ref); err == nil { return resolved, nil } else if !isNotFound(err) { return nil, err @@ -263,7 +267,11 @@ func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*Res var commitResp struct { SHA string `json:"sha"` } - if err := client.REST(host, "GET", commitPath.String(), nil, &commitResp); err == nil { + req, err := client.NewRequest(ctx, http.MethodGet, commitPath.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(req, &commitResp); err == nil { return &ResolvedRef{Ref: commitResp.SHA, SHA: commitResp.SHA}, nil } else if !isNotFound(err) { return nil, err @@ -274,7 +282,7 @@ func resolveExplicitRef(client *api.Client, host, owner, repo, ref string) (*Res // resolveTagRef looks up a tag by short name and returns a fully qualified ref. // For annotated tags, the tag object is dereferenced to obtain the commit SHA. -func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*ResolvedRef, error) { +func resolveTagRef(ctx context.Context, client *githubrest.Client, owner, repo, tag string) (*ResolvedRef, error) { tagPath, err := safeurl.JoinPath("repos", owner, repo, "git", "ref", fmt.Sprintf("tags/%s", tag)) if err != nil { return nil, err @@ -285,7 +293,11 @@ func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*Resolved Type string `json:"type"` } `json:"object"` } - if err := client.REST(host, "GET", tagPath.String(), nil, &refResp); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, tagPath.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(req, &refResp); err != nil { return nil, fmt.Errorf("tag %q not found in %s/%s: %w", tag, owner, repo, err) } sha := refResp.Object.SHA @@ -299,7 +311,11 @@ func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*Resolved SHA string `json:"sha"` } `json:"object"` } - if err := client.REST(host, "GET", derefPath.String(), nil, &tagResp); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, derefPath.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(req, &tagResp); err != nil { return nil, fmt.Errorf("could not dereference annotated tag %q: %w", tag, err) } sha = tagResp.Object.SHA @@ -308,7 +324,7 @@ func resolveTagRef(client *api.Client, host, owner, repo, tag string) (*Resolved } // resolveBranchRef looks up a branch by short name and returns a fully qualified ref. -func resolveBranchRef(client *api.Client, host, owner, repo, branch string) (*ResolvedRef, error) { +func resolveBranchRef(ctx context.Context, client *githubrest.Client, owner, repo, branch string) (*ResolvedRef, error) { refPath, err := safeurl.JoinPath("repos", owner, repo, "git", "ref", fmt.Sprintf("heads/%s", branch)) if err != nil { return nil, err @@ -318,7 +334,11 @@ func resolveBranchRef(client *api.Client, host, owner, repo, branch string) (*Re SHA string `json:"sha"` } `json:"object"` } - if err := client.REST(host, "GET", refPath.String(), nil, &refResp); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, refPath.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(req, &refResp); err != nil { return nil, fmt.Errorf("branch %q not found in %s/%s: %w", branch, owner, repo, err) } return &ResolvedRef{Ref: "refs/heads/" + branch, SHA: refResp.Object.SHA}, nil @@ -339,7 +359,7 @@ type noReleasesError struct { func (e *noReleasesError) Error() string { return e.reason } -func resolveLatestRelease(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { +func resolveLatestRelease(ctx context.Context, client *githubrest.Client, owner, repo string) (*ResolvedRef, error) { apiPath, err := safeurl.JoinPath("repos", owner, repo, "releases", "latest") if err != nil { return nil, err @@ -347,7 +367,11 @@ func resolveLatestRelease(client *api.Client, host, owner, repo string) (*Resolv var resp struct { TagName string `json:"tag_name"` } - if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(req, &resp); err != nil { // A 404 means the repository has no releases. This is the // only case where falling back to the default branch is safe. // Any other HTTP error (403, 500, …) or network failure is @@ -361,10 +385,10 @@ func resolveLatestRelease(client *api.Client, host, owner, repo string) (*Resolv if resp.TagName == "" { return nil, &noReleasesError{reason: "latest release has no tag"} } - return resolveTagRef(client, host, owner, repo, resp.TagName) + return resolveTagRef(ctx, client, owner, repo, resp.TagName) } -func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*ResolvedRef, error) { +func resolveDefaultBranch(ctx context.Context, client *githubrest.Client, owner, repo string) (*ResolvedRef, error) { apiPath, err := safeurl.JoinPath("repos", owner, repo) if err != nil { return nil, err @@ -372,14 +396,18 @@ func resolveDefaultBranch(client *api.Client, host, owner, repo string) (*Resolv var resp struct { DefaultBranch string `json:"default_branch"` } - if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(req, &resp); err != nil { return nil, fmt.Errorf("could not determine default branch: %w", err) } branch := resp.DefaultBranch if branch == "" { return nil, fmt.Errorf("could not determine default branch for %s/%s", owner, repo) } - return resolveBranchRef(client, host, owner, repo, branch) + return resolveBranchRef(ctx, client, owner, repo, branch) } // skillMatch represents a matched SKILL.md file and its convention. @@ -548,8 +576,8 @@ type DiscoverOptions struct { // DiscoverSkills finds all non-hidden-dir skills in a repository at the given // commit SHA. Hidden-dir skills are excluded; use DiscoverSkillsWithOptions to // retrieve all skills including those in hidden directories. -func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([]Skill, error) { - all, err := DiscoverSkillsWithOptions(client, host, owner, repo, commitSHA, DiscoverOptions{}) +func DiscoverSkills(ctx context.Context, client *githubrest.Client, owner, repo, commitSHA string) ([]Skill, error) { + all, err := DiscoverSkillsWithOptions(ctx, client, owner, repo, commitSHA, DiscoverOptions{}) if err != nil { return nil, err } @@ -573,14 +601,18 @@ func DiscoverSkills(client *api.Client, host, owner, repo, commitSHA string) ([] // DiscoverSkillsWithOptions finds all skills in a repository at the given // commit SHA, with configurable discovery behavior. -func DiscoverSkillsWithOptions(client *api.Client, host, owner, repo, commitSHA string, opts DiscoverOptions) ([]Skill, error) { +func DiscoverSkillsWithOptions(ctx context.Context, client *githubrest.Client, owner, repo, commitSHA string, opts DiscoverOptions) ([]Skill, error) { apiPath, err := safeurl.JoinPath("repos", owner, repo, "git", "trees", commitSHA) if err != nil { return nil, err } apiPath.SetQuery("recursive", "true") var tree treeResponse - if err := client.REST(host, "GET", apiPath.String(), nil, &tree); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(req, &tree); err != nil { return nil, fmt.Errorf("could not fetch repository tree: %w", err) } @@ -646,11 +678,11 @@ func DiscoverSkillsWithOptions(client *api.Client, host, owner, repo, commitSHA } // fetchDescription fetches and parses the frontmatter description for a skill. -func fetchDescription(client *api.Client, host, owner, repo string, skill *Skill) string { +func fetchDescription(ctx context.Context, client *githubrest.Client, owner, repo string, skill *Skill) string { if skill.BlobSHA == "" { return "" } - content, err := FetchBlob(client, host, owner, repo, skill.BlobSHA) + content, err := FetchBlob(ctx, client, owner, repo, skill.BlobSHA) if err != nil { return "" } @@ -662,7 +694,7 @@ func fetchDescription(client *api.Client, host, owner, repo string, skill *Skill } // FetchDescriptionsConcurrent fetches descriptions with bounded concurrency. -func FetchDescriptionsConcurrent(client *api.Client, host, owner, repo string, skills []Skill, onProgress func(done, total int)) { +func FetchDescriptionsConcurrent(ctx context.Context, client *githubrest.Client, owner, repo string, skills []Skill, onProgress func(done, total int)) { total := 0 for _, s := range skills { if s.Description == "" { @@ -683,7 +715,7 @@ func FetchDescriptionsConcurrent(client *api.Client, host, owner, repo string, s for range workers { wg.Go(func() { for s := range jobs { - s.Description = fetchDescription(client, host, owner, repo, s) + s.Description = fetchDescription(ctx, client, owner, repo, s) d := int(done.Add(1)) if onProgress != nil { @@ -708,13 +740,13 @@ type DiscoverSkillByPathOptions struct { } // DiscoverSkillByPath looks up a single skill by its exact path in the repository. -func DiscoverSkillByPath(client *api.Client, host, owner, repo, commitSHA, skillPath string) (*Skill, error) { - return DiscoverSkillByPathWithOptions(client, host, owner, repo, commitSHA, skillPath, DiscoverSkillByPathOptions{}) +func DiscoverSkillByPath(ctx context.Context, client *githubrest.Client, owner, repo, commitSHA, skillPath string) (*Skill, error) { + return DiscoverSkillByPathWithOptions(ctx, client, owner, repo, commitSHA, skillPath, DiscoverSkillByPathOptions{}) } // DiscoverSkillByPathWithOptions looks up a single skill by its exact path in // the repository, applying the given options. -func DiscoverSkillByPathWithOptions(client *api.Client, host, owner, repo, commitSHA, skillPath string, opts DiscoverSkillByPathOptions) (*Skill, error) { +func DiscoverSkillByPathWithOptions(ctx context.Context, client *githubrest.Client, owner, repo, commitSHA, skillPath string, opts DiscoverSkillByPathOptions) (*Skill, error) { skillPath = strings.TrimSuffix(skillPath, "/SKILL.md") skillPath = strings.TrimSuffix(skillPath, "/") @@ -736,7 +768,11 @@ func DiscoverSkillByPathWithOptions(client *api.Client, host, owner, repo, commi SHA string `json:"sha"` Type string `json:"type"` } - if err := client.REST(host, "GET", apiPath.String(), nil, &contents); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(req, &contents); err != nil { return nil, fmt.Errorf("path %q not found in %s/%s: %w", parentPath, owner, repo, err) } @@ -756,7 +792,11 @@ func DiscoverSkillByPathWithOptions(client *api.Client, host, owner, repo, commi return nil, err } var skillTree treeResponse - if err := client.REST(host, "GET", skillTreePath.String(), nil, &skillTree); err != nil { + treeReq, err := client.NewRequest(ctx, http.MethodGet, skillTreePath.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(treeReq, &skillTree); err != nil { return nil, fmt.Errorf("could not read skill directory: %w", err) } @@ -803,7 +843,7 @@ func DiscoverSkillByPathWithOptions(client *api.Client, host, owner, repo, commi } if !opts.SkipDescription { - skill.Description = fetchDescription(client, host, owner, repo, skill) + skill.Description = fetchDescription(ctx, client, owner, repo, skill) } return skill, nil @@ -811,20 +851,24 @@ func DiscoverSkillByPathWithOptions(client *api.Client, host, owner, repo, commi // DiscoverSkillFiles returns all file paths belonging to a skill directory // by fetching the skill's subtree directly using its tree SHA. -func DiscoverSkillFiles(client *api.Client, host, owner, repo, treeSHA, skillPath string) ([]SkillFile, error) { +func DiscoverSkillFiles(ctx context.Context, client *githubrest.Client, owner, repo, treeSHA, skillPath string) ([]SkillFile, error) { apiPath, err := safeurl.JoinPath("repos", owner, repo, "git", "trees", treeSHA) if err != nil { return nil, err } apiPath.SetQuery("recursive", "true") var tree treeResponse - if err := client.REST(host, "GET", apiPath.String(), nil, &tree); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(req, &tree); err != nil { return nil, fmt.Errorf("could not fetch skill tree: %w", err) } if tree.Truncated { // Recursive fetch was truncated. Fall back to walking subtrees individually. - return walkTree(client, host, owner, repo, treeSHA, skillPath, 0) + return walkTree(ctx, client, owner, repo, treeSHA, skillPath, 0) } var files []SkillFile @@ -843,20 +887,24 @@ func DiscoverSkillFiles(client *api.Client, host, owner, repo, treeSHA, skillPat // ListSkillFiles returns all files in a skill directory as public SkillFile // structs with paths relative to the skill root. -func ListSkillFiles(client *api.Client, host, owner, repo, treeSHA string) ([]SkillFile, error) { +func ListSkillFiles(ctx context.Context, client *githubrest.Client, owner, repo, treeSHA string) ([]SkillFile, error) { apiPath, err := safeurl.JoinPath("repos", owner, repo, "git", "trees", treeSHA) if err != nil { return nil, err } apiPath.SetQuery("recursive", "true") var tree treeResponse - if err := client.REST(host, "GET", apiPath.String(), nil, &tree); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(req, &tree); err != nil { return nil, fmt.Errorf("could not fetch skill tree: %w", err) } if tree.Truncated { // Fall back to non-recursive traversal when the tree is too large. - return walkTree(client, host, owner, repo, treeSHA, "", 0) + return walkTree(ctx, client, owner, repo, treeSHA, "", 0) } var files []SkillFile @@ -879,7 +927,7 @@ const maxTreeDepth = 20 // walkTree enumerates files by fetching each tree level individually, // avoiding the truncation limit of the recursive tree API. Recursion // depth is bounded by maxTreeDepth to prevent unbounded API calls. -func walkTree(client *api.Client, host, owner, repo, sha, prefix string, depth int) ([]SkillFile, error) { +func walkTree(ctx context.Context, client *githubrest.Client, owner, repo, sha, prefix string, depth int) ([]SkillFile, error) { if depth > maxTreeDepth { return nil, fmt.Errorf("tree depth exceeds %d levels at %s", maxTreeDepth, prefix) } @@ -888,7 +936,11 @@ func walkTree(client *api.Client, host, owner, repo, sha, prefix string, depth i return nil, err } var tree treeResponse - if err := client.REST(host, "GET", apiPath.String(), nil, &tree); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(req, &tree); err != nil { return nil, fmt.Errorf("could not fetch tree %s: %w", prefix, err) } @@ -902,7 +954,7 @@ func walkTree(client *api.Client, host, owner, repo, sha, prefix string, depth i case "blob": files = append(files, SkillFile{Path: entryPath, SHA: entry.SHA, Size: entry.Size}) case "tree": - sub, err := walkTree(client, host, owner, repo, entry.SHA, entryPath, depth+1) + sub, err := walkTree(ctx, client, owner, repo, entry.SHA, entryPath, depth+1) if err != nil { return nil, err } @@ -916,7 +968,7 @@ func walkTree(client *api.Client, host, owner, repo, sha, prefix string, depth i // inside the JSON response and decoded here, so it is returned as // iostreams.Untrusted and callers must choose sanitized display or raw // round-tripping. -func FetchBlob(client *api.Client, host, owner, repo, sha string) (iostreams.Untrusted, error) { +func FetchBlob(ctx context.Context, client *githubrest.Client, owner, repo, sha string) (iostreams.Untrusted, error) { apiPath, err := safeurl.JoinPath("repos", owner, repo, "git", "blobs", sha) if err != nil { return iostreams.Untrusted{}, err @@ -926,7 +978,11 @@ func FetchBlob(client *api.Client, host, owner, repo, sha string) (iostreams.Unt Content string `json:"content"` Encoding string `json:"encoding"` } - if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return iostreams.Untrusted{}, err + } + if _, err := client.Do(req, &resp); err != nil { return iostreams.Untrusted{}, fmt.Errorf("could not fetch blob: %w", err) } diff --git a/internal/skills/discovery/discovery_test.go b/internal/skills/discovery/discovery_test.go index cc7c35104a7..33b7469ce87 100644 --- a/internal/skills/discovery/discovery_test.go +++ b/internal/skills/discovery/discovery_test.go @@ -1,14 +1,12 @@ package discovery import ( - "net/http" "os" "path/filepath" "strings" "testing" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -704,9 +702,10 @@ func TestResolveRef(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) tt.stubs(reg) - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) - ref, err := ResolveRef(client, "github.com", "monalisa", "octocat-skills", tt.version) + ref, err := ResolveRef(t.Context(), client, "monalisa", "octocat-skills", tt.version) if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) @@ -763,9 +762,10 @@ func TestFetchBlob(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) tt.stubs(reg) - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) - got, err := FetchBlob(client, "github.com", "monalisa", "octocat-skills", "abc") + got, err := FetchBlob(t.Context(), client, "monalisa", "octocat-skills", "abc") if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) @@ -843,9 +843,10 @@ func TestFetchRepoVisibility(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) tt.stubs(reg) - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) - got, err := FetchRepoVisibility(client, "github.com", "monalisa", "octocat-skills") + got, err := FetchRepoVisibility(t.Context(), client, "monalisa", "octocat-skills") if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) @@ -973,9 +974,10 @@ func TestDiscoverSkills(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) tt.stubs(reg) - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) - skills, err := DiscoverSkills(client, "github.com", "monalisa", "octocat-skills", "abc123") + skills, err := DiscoverSkills(t.Context(), client, "monalisa", "octocat-skills", "abc123") if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) @@ -1064,9 +1066,10 @@ func TestDiscoverSkillsWithOptions(t *testing.T) { reg.Register( httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/abc123"), httpmock.JSONResponse(tt.tree)) - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) - skills, err := DiscoverSkillsWithOptions(client, "github.com", "monalisa", "octocat-skills", "abc123", DiscoverOptions{}) + skills, err := DiscoverSkillsWithOptions(t.Context(), client, "monalisa", "octocat-skills", "abc123", DiscoverOptions{}) if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) @@ -1316,9 +1319,10 @@ func TestDiscoverSkillByPath(t *testing.T) { if tt.stubs != nil { tt.stubs(reg) } - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) - skill, err := DiscoverSkillByPath(client, "github.com", "monalisa", "octocat-skills", "abc123", tt.skillPath) + skill, err := DiscoverSkillByPath(t.Context(), client, "monalisa", "octocat-skills", "abc123", tt.skillPath) if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) @@ -1352,8 +1356,9 @@ func TestDiscoverSkillByPathWithOptionsSkipsDescription(t *testing.T) { }, })) - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) - skill, err := DiscoverSkillByPathWithOptions(client, "github.com", "monalisa", "octocat-skills", "abc123", "skills/code-review", DiscoverSkillByPathOptions{SkipDescription: true}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + skill, err := DiscoverSkillByPathWithOptions(t.Context(), client, "monalisa", "octocat-skills", "abc123", "skills/code-review", DiscoverSkillByPathOptions{SkipDescription: true}) require.NoError(t, err) assert.Equal(t, "code-review", skill.Name) @@ -1650,9 +1655,10 @@ func TestDiscoverSkillFiles(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) tt.stubs(reg) - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) - files, err := DiscoverSkillFiles(client, "github.com", "monalisa", "octocat-skills", "tree123", "skills/code-review") + files, err := DiscoverSkillFiles(t.Context(), client, "monalisa", "octocat-skills", "tree123", "skills/code-review") if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) @@ -1735,9 +1741,10 @@ func TestListSkillFiles(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) tt.stubs(reg) - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) - files, err := ListSkillFiles(client, "github.com", "monalisa", "octocat-skills", "tree123") + files, err := ListSkillFiles(t.Context(), client, "monalisa", "octocat-skills", "tree123") if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) @@ -1790,9 +1797,10 @@ func TestFetchDescriptionsConcurrent(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) tt.stubs(reg) - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) - FetchDescriptionsConcurrent(client, "github.com", "monalisa", "octocat-skills", tt.skills, nil) + FetchDescriptionsConcurrent(t.Context(), client, "monalisa", "octocat-skills", tt.skills, nil) var descs []string for _, s := range tt.skills { descs = append(descs, s.Description) diff --git a/internal/skills/installer/installer.go b/internal/skills/installer/installer.go index a0b0bfb708f..202135a0769 100644 --- a/internal/skills/installer/installer.go +++ b/internal/skills/installer/installer.go @@ -10,8 +10,8 @@ import ( "sync" "sync/atomic" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safepaths" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" @@ -36,7 +36,7 @@ type Options struct { Dir string // explicit target directory (overrides AgentHost+Scope) GitRoot string // git repository root (for project scope) HomeDir string // user home directory (for user scope) - Client *api.Client + Client *githubrest.Client OnProgress func(done, total int) // called after each skill is installed } @@ -53,7 +53,7 @@ type skillResult struct { } // Install fetches and writes skills to the target directory. -func Install(opts *Options) (*Result, error) { +func Install(ctx context.Context, opts *Options) (*Result, error) { targetDir := opts.Dir if targetDir == "" { if opts.AgentHost == nil { @@ -72,7 +72,7 @@ func Install(opts *Options) (*Result, error) { opts.OnProgress(0, 1) defer opts.OnProgress(1, 1) } - if err := installSkill(opts, skill, targetDir); err != nil { + if err := installSkill(ctx, opts, skill, targetDir); err != nil { return nil, fmt.Errorf("failed to install skill %q: %w", skill.InstallName(), err) } var warnings []string @@ -101,7 +101,7 @@ func Install(opts *Options) (*Result, error) { for range workers { wg.Go(func() { for j := range jobs { - err := installSkill(opts, j.skill, targetDir) + err := installSkill(ctx, opts, j.skill, targetDir) results[j.idx] = skillResult{name: j.skill.InstallName(), err: err} if opts.OnProgress != nil { @@ -248,14 +248,14 @@ func installLocalSkill(sourceRoot string, skill discovery.Skill, baseDir string) }) } -func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { +func installSkill(ctx context.Context, opts *Options, skill discovery.Skill, baseDir string) error { // Use skill.Name (not InstallName) for a flat directory layout. skillDir := filepath.Join(baseDir, skill.Name) if err := os.MkdirAll(skillDir, 0o755); err != nil { return fmt.Errorf("could not create directory %s: %w", skillDir, err) } - files, err := discovery.DiscoverSkillFiles(opts.Client, opts.Host, opts.Owner, opts.Repo, skill.TreeSHA, skill.Path) + files, err := discovery.DiscoverSkillFiles(ctx, opts.Client, opts.Owner, opts.Repo, skill.TreeSHA, skill.Path) if err != nil { return fmt.Errorf("could not list skill files: %w", err) } @@ -266,7 +266,7 @@ func installSkill(opts *Options, skill discovery.Skill, baseDir string) error { } for _, file := range files { - fetchedContent, err := discovery.FetchBlob(opts.Client, opts.Host, opts.Owner, opts.Repo, file.SHA) + fetchedContent, err := discovery.FetchBlob(ctx, opts.Client, opts.Owner, opts.Repo, file.SHA) if err != nil { return fmt.Errorf("could not fetch %s: %w", file.Path, err) } diff --git a/internal/skills/installer/installer_test.go b/internal/skills/installer/installer_test.go index e05a3541e9a..608d2ffda30 100644 --- a/internal/skills/installer/installer_test.go +++ b/internal/skills/installer/installer_test.go @@ -3,13 +3,11 @@ package installer import ( "encoding/base64" "fmt" - "net/http" "os" "path/filepath" "sync/atomic" "testing" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/registry" @@ -291,7 +289,8 @@ func TestInstallSkill(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) tt.stubs(reg) - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) opts := &Options{ Host: "github.com", Owner: "monalisa", @@ -301,7 +300,7 @@ func TestInstallSkill(t *testing.T) { Client: client, } - err := installSkill(opts, tt.skill, destDir) + err = installSkill(t.Context(), opts, tt.skill, destDir) if tt.name == "fails on path traversal from malicious tree" { require.Error(t, err) assert.Contains(t, err.Error(), "blocked path traversal") @@ -404,7 +403,8 @@ func TestInstall(t *testing.T) { reg := &httpmock.Registry{} defer reg.Verify(t) tt.stubs(reg) - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) opts := &Options{ Host: "github.com", @@ -421,7 +421,7 @@ func TestInstall(t *testing.T) { opts.Dir = "" } - result, err := Install(opts) + result, err := Install(t.Context(), opts) if tt.wantErr != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErr) @@ -461,10 +461,11 @@ func TestInstallSingleSkillFailureStillCompletesProgress(t *testing.T) { httpmock.REST("GET", "repos/monalisa/octocat-skills/git/trees/tree-fail"), httpmock.StatusStringResponse(500, "server error"), ) - client := api.NewClientFromHTTP(&http.Client{Transport: reg}) + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) var events []struct{ done, total int } - result, err := Install(&Options{ + result, err := Install(t.Context(), &Options{ Host: "github.com", Owner: "monalisa", Repo: "octocat-skills", diff --git a/pkg/cmd/extension/browse/browse.go b/pkg/cmd/extension/browse/browse.go index 21d254956f7..498dcf4e5ce 100644 --- a/pkg/cmd/extension/browse/browse.go +++ b/pkg/cmd/extension/browse/browse.go @@ -5,16 +5,15 @@ import ( "fmt" "io" "log" - "net/http" "os" "strings" - "time" "github.com/MakeNowJust/heredoc" "github.com/charmbracelet/glamour" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/search" @@ -31,7 +30,7 @@ type ExtBrowseOpts struct { IO *iostreams.IOStreams Searcher search.Searcher Em extensions.ExtensionManager - Client *http.Client + GitHubREST *githubrest.Client Logger *log.Logger Cfg gh.Config Rg *readmeGetter @@ -397,7 +396,7 @@ func ExtBrowse(opts ExtBrowseOpts) error { return err } - opts.Rg = newReadmeGetter(opts.Client, time.Hour*24) + opts.Rg = newReadmeGetter(opts.Cmd.Context(), opts.GitHubREST) app := tview.NewApplication() diff --git a/pkg/cmd/extension/browse/browse_test.go b/pkg/cmd/extension/browse/browse_test.go index 126b0592a38..c1f0281e6bb 100644 --- a/pkg/cmd/extension/browse/browse_test.go +++ b/pkg/cmd/extension/browse/browse_test.go @@ -5,11 +5,9 @@ import ( "encoding/base64" "io" "log" - "net/http" "net/url" "sync" "testing" - "time" "github.com/cli/cli/v2/internal/config" fd "github.com/cli/cli/v2/internal/featuredetection" @@ -34,9 +32,10 @@ func Test_getSelectedReadme(t *testing.T) { httpmock.REST("GET", "repos/cli/gh-cool/readme"), httpmock.JSONResponse(view.RepoReadme{Content: content})) - client := &http.Client{Transport: ®} + restClient, err := httpmock.RESTClientFunc(®)("github.com") + require.NoError(t, err) - rg := newReadmeGetter(client, time.Second) + rg := newReadmeGetter(context.Background(), restClient) opts := ExtBrowseOpts{ Rg: rg, } @@ -62,7 +61,7 @@ func Test_getSelectedReadme(t *testing.T) { } el := newExtList(opts, ui, extEntries) - content, err := getSelectedReadme(opts, readme, el) + content, err = getSelectedReadme(opts, readme, el) assert.NoError(t, err) assert.Contains(t, content, "lol") } diff --git a/pkg/cmd/extension/browse/rg.go b/pkg/cmd/extension/browse/rg.go index 4884b177988..8312e9146b6 100644 --- a/pkg/cmd/extension/browse/rg.go +++ b/pkg/cmd/extension/browse/rg.go @@ -1,22 +1,25 @@ package browse import ( - "net/http" - "time" + "context" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/repo/view" ) type readmeGetter struct { - client *http.Client + client *githubrest.Client + + // ctx is stored on the struct because Get is called from the interactive + // browse UI, which has no request context of its own to thread through. + ctx context.Context } -func newReadmeGetter(client *http.Client, cacheTTL time.Duration) *readmeGetter { - cachingClient := api.NewCachedHTTPClient(client, cacheTTL) +func newReadmeGetter(ctx context.Context, client *githubrest.Client) *readmeGetter { return &readmeGetter{ - client: cachingClient, + client: client, + ctx: ctx, } } @@ -25,7 +28,7 @@ func (g *readmeGetter) Get(repoFullName string) (string, error) { if err != nil { return "", err } - readme, err := view.RepositoryReadme(g.client, repo, "") + readme, err := view.RepositoryReadme(g.ctx, g.client, repo, "") if err != nil { return "", err } diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 458d66570ad..4c686298b8b 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -534,7 +534,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { Browser: browser, Searcher: searcher, Em: m, - Client: client, + GitHubREST: restClient, Cfg: cfg, Debug: debug, SingleColumn: singleColumn, diff --git a/pkg/cmd/extension/extension.go b/pkg/cmd/extension/extension.go index f30bf63c15c..d8b14f99b17 100644 --- a/pkg/cmd/extension/extension.go +++ b/pkg/cmd/extension/extension.go @@ -2,6 +2,7 @@ package extension import ( "bytes" + "context" "fmt" "net/http" "os" @@ -129,7 +130,14 @@ func (e *Extension) LatestVersion() string { if err != nil { return "" } - release, err := fetchLatestRelease(e.httpClient, repo) + restClient, err := restClientFromHTTP(e.httpClient, repo.RepoHost()) + if err != nil { + return "" + } + // LatestVersion is part of the extensions.Extension interface, which + // carries no context and widening it is out of scope, so a background + // context is used here. + release, err := fetchLatestRelease(context.Background(), restClient, repo) if err != nil { return "" } diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go index 689f717c43a..abdfa522d93 100644 --- a/pkg/cmd/extension/http.go +++ b/pkg/cmd/extension/http.go @@ -1,19 +1,30 @@ package extension import ( + "context" "encoding/json" "errors" "io" "net/http" "os" - "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/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) +// restClientFromHTTP builds a githubrest.Client that reaches host through +// httpClient. +// +// The extension manager and each Extension hold an already-authenticated +// *http.Client whose transport carries the token, so the client is constructed +// WithoutToken and leans on that transport for auth, exactly as the api.Client +// it replaced did. +func restClientFromHTTP(httpClient *http.Client, host string) (*githubrest.Client, error) { + return githubrest.NewClient(ghinstance.RESTPrefix(host), httpClient, githubrest.WithoutToken()) +} + 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 { @@ -39,11 +50,11 @@ func repoExists(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { case http.StatusNotFound: return false, nil default: - return false, api.HandleHTTPError(resp) + return false, githubrest.NewErrorResponse(resp) } } -func hasScript(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { +func hasScript(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface) (bool, error) { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "contents", repo.RepoName()) if err != nil { return false, err @@ -51,11 +62,11 @@ func hasScript(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { // 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, nil) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) if err != nil { + return false, err + } + if _, err := client.Do(req, nil); err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { return false, nil @@ -94,7 +105,7 @@ func downloadAsset(httpClient *http.Client, assetURL safeurl.SafeURL, destPath s defer resp.Body.Close() if resp.StatusCode > 299 { - downloadErr = api.HandleHTTPError(resp) + downloadErr = githubrest.NewErrorResponse(resp) return } @@ -117,18 +128,18 @@ var releaseNotFoundErr = errors.New("release not found") 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) { +func fetchLatestRelease(ctx context.Context, client *githubrest.Client, baseRepo ghrepo.Interface) (*release, error) { path, err := safeurl.JoinPath("repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "releases", "latest") if err != nil { return nil, err } 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) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) if err != nil { + return nil, err + } + if _, err := client.Do(req, &data); err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { return nil, releaseNotFoundErr @@ -145,18 +156,18 @@ 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) { +func fetchReleaseFromTag(ctx context.Context, client *githubrest.Client, baseRepo ghrepo.Interface, tagName string) (*release, error) { path, err := safeurl.JoinPath("repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "releases", "tags", tagName) if err != nil { return nil, err } 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) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) if err != nil { + return nil, err + } + if _, err := client.Do(req, &data); err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { return nil, releaseNotFoundErr @@ -196,7 +207,7 @@ func fetchCommitSHA(httpClient *http.Client, baseRepo ghrepo.Interface, targetRe return "", commitNotFoundErr } if resp.StatusCode > 299 { - return "", api.HandleHTTPError(resp) + return "", githubrest.NewErrorResponse(resp) } body, err := io.ReadAll(resp.Body) diff --git a/pkg/cmd/extension/http_test.go b/pkg/cmd/extension/http_test.go index 22e029eeac2..1f63bf95c97 100644 --- a/pkg/cmd/extension/http_test.go +++ b/pkg/cmd/extension/http_test.go @@ -26,6 +26,17 @@ func extensionHTTPClient(t *testing.T, path string, status int, body string) *ht return &http.Client{Transport: reg} } +// extensionRESTClient wraps extensionHTTPClient in a githubrest.Client, for the +// helpers that now take one. The host is arbitrary because the registry matches +// on method and path. +func extensionRESTClient(t *testing.T, path string, status int, body string) *githubrest.Client { + t.Helper() + + client, err := restClientFromHTTP(extensionHTTPClient(t, path, status, body), "github.com") + require.NoError(t, err) + return client +} + func requireExtensionHTTPError(t *testing.T, err error, status int) { t.Helper() @@ -87,9 +98,9 @@ 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"}`) + client := extensionRESTClient(t, "repos/OWNER/REPO/contents/REPO", http.StatusOK, `{"type":"file"}`) - hasScript, err := hasScript(client, repo) + hasScript, err := hasScript(t.Context(), client, repo) require.NoError(t, err) assert.True(t, hasScript) @@ -106,9 +117,9 @@ func TestHasScript(t *testing.T) { "submodule": `{"type":"submodule"}`, } { t.Run(name, func(t *testing.T) { - client := extensionHTTPClient(t, "repos/OWNER/REPO/contents/REPO", http.StatusOK, body) + client := extensionRESTClient(t, "repos/OWNER/REPO/contents/REPO", http.StatusOK, body) - hasScript, err := hasScript(client, repo) + hasScript, err := hasScript(t.Context(), client, repo) require.NoError(t, err) assert.True(t, hasScript) @@ -117,18 +128,18 @@ func TestHasScript(t *testing.T) { }) t.Run("not found", func(t *testing.T) { - client := extensionHTTPClient(t, "repos/OWNER/REPO/contents/REPO", http.StatusNotFound, `{"message":"Not Found"}`) + client := extensionRESTClient(t, "repos/OWNER/REPO/contents/REPO", http.StatusNotFound, `{"message":"Not Found"}`) - hasScript, err := hasScript(client, repo) + hasScript, err := hasScript(t.Context(), 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"}`) + client := extensionRESTClient(t, "repos/OWNER/REPO/contents/REPO", http.StatusInternalServerError, `{"message":"Internal Server Error"}`) - hasScript, err := hasScript(client, repo) + hasScript, err := hasScript(t.Context(), client, repo) assert.False(t, hasScript) requireExtensionHTTPError(t, err, http.StatusInternalServerError) @@ -139,9 +150,9 @@ 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"}]}`) + client := extensionRESTClient(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) + got, err := fetchLatestRelease(t.Context(), client, repo) require.NoError(t, err) assert.Equal(t, &release{ @@ -154,18 +165,18 @@ func TestFetchLatestRelease(t *testing.T) { }) t.Run("not found", func(t *testing.T) { - client := extensionHTTPClient(t, "repos/OWNER/REPO/releases/latest", http.StatusNotFound, `{"message":"Not Found"}`) + client := extensionRESTClient(t, "repos/OWNER/REPO/releases/latest", http.StatusNotFound, `{"message":"Not Found"}`) - got, err := fetchLatestRelease(client, repo) + got, err := fetchLatestRelease(t.Context(), 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"}`) + client := extensionRESTClient(t, "repos/OWNER/REPO/releases/latest", http.StatusInternalServerError, `{"message":"Internal Server Error"}`) - got, err := fetchLatestRelease(client, repo) + got, err := fetchLatestRelease(t.Context(), client, repo) assert.Nil(t, got) requireExtensionHTTPError(t, err, http.StatusInternalServerError) @@ -173,9 +184,9 @@ func TestFetchLatestRelease(t *testing.T) { 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, "") + client := extensionRESTClient(t, "repos/OWNER/REPO/releases/latest", status, "") - got, err := fetchLatestRelease(client, repo) + got, err := fetchLatestRelease(t.Context(), client, repo) assert.Nil(t, got) require.EqualError(t, err, "unexpected end of JSON input") @@ -187,9 +198,9 @@ 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"}]}`) + client := extensionRESTClient(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") + got, err := fetchReleaseFromTag(t.Context(), client, repo, "v1.2.3") require.NoError(t, err) assert.Equal(t, &release{ @@ -202,18 +213,18 @@ func TestFetchReleaseFromTag(t *testing.T) { }) t.Run("not found", func(t *testing.T) { - client := extensionHTTPClient(t, "repos/OWNER/REPO/releases/tags/v1.2.3", http.StatusNotFound, `{"message":"Not Found"}`) + client := extensionRESTClient(t, "repos/OWNER/REPO/releases/tags/v1.2.3", http.StatusNotFound, `{"message":"Not Found"}`) - got, err := fetchReleaseFromTag(client, repo, "v1.2.3") + got, err := fetchReleaseFromTag(t.Context(), 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"}`) + client := extensionRESTClient(t, "repos/OWNER/REPO/releases/tags/v1.2.3", http.StatusInternalServerError, `{"message":"Internal Server Error"}`) - got, err := fetchReleaseFromTag(client, repo, "v1.2.3") + got, err := fetchReleaseFromTag(t.Context(), client, repo, "v1.2.3") assert.Nil(t, got) requireExtensionHTTPError(t, err, http.StatusInternalServerError) @@ -221,9 +232,9 @@ func TestFetchReleaseFromTag(t *testing.T) { 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, "") + client := extensionRESTClient(t, "repos/OWNER/REPO/releases/tags/v1.2.3", status, "") - got, err := fetchReleaseFromTag(client, repo, "v1.2.3") + got, err := fetchReleaseFromTag(t.Context(), 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.go b/pkg/cmd/extension/manager.go index 82b7fbfe0f8..faaf40c717a 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -1,6 +1,7 @@ package extension import ( + "context" _ "embed" "errors" "fmt" @@ -268,7 +269,13 @@ func (m *Manager) Install(repo ghrepo.Interface, target string) error { return m.installBin(repo, target) } - hs, err := hasScript(m.client, repo) + restClient, err := restClientFromHTTP(m.client, repo.RepoHost()) + if err != nil { + return err + } + // The ExtensionManager interface method carries no context and widening it + // is out of scope, so a background context is used here. + hs, err := hasScript(context.Background(), restClient, repo) if err != nil { return err } @@ -282,11 +289,17 @@ func (m *Manager) Install(repo ghrepo.Interface, target string) error { func (m *Manager) installBin(repo ghrepo.Interface, target string) error { var r *release var err error + restClient, err := restClientFromHTTP(m.client, repo.RepoHost()) + if err != nil { + return err + } isPinned := target != "" + // The ExtensionManager interface method carries no context and widening it + // is out of scope, so a background context is used here. if isPinned { - r, err = fetchReleaseFromTag(m.client, repo, target) + r, err = fetchReleaseFromTag(context.Background(), restClient, repo, target) } else { - r, err = fetchLatestRelease(m.client, repo) + r, err = fetchLatestRelease(context.Background(), restClient, repo) } if err != nil { return err @@ -745,9 +758,16 @@ func readPathFromFile(path string) (string, error) { return strings.TrimSpace(string(b[:n])), err } -func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err error) { +func isBinExtension(httpClient *http.Client, repo ghrepo.Interface) (isBin bool, err error) { + restClient, err := restClientFromHTTP(httpClient, repo.RepoHost()) + if err != nil { + return false, err + } var r *release - r, err = fetchLatestRelease(client, repo) + // This is called from ExtensionManager interface methods that carry no + // context and widening them is out of scope, so a background context is + // used here. + r, err = fetchLatestRelease(context.Background(), restClient, repo) if err != nil { return } diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go index 063501ef7ed..3770d98971b 100644 --- a/pkg/cmd/pr/close/close.go +++ b/pkg/cmd/pr/close/close.go @@ -8,6 +8,7 @@ import ( "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/githubrest" "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -16,6 +17,7 @@ import ( type CloseOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) GitClient *git.Client IO *iostreams.IOStreams Branch func() (string, error) @@ -32,6 +34,7 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm opts := &CloseOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, GitClient: f.GitClient, Branch: f.Branch, } @@ -153,7 +156,11 @@ func closeRun(opts *CloseOptions) error { return nil } } else { - if err := api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName); err != nil { + restClient, err := opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return err + } + if err := shared.BranchDeleteRemote(ctx, restClient, baseRepo, pr.HeadRefName); err != nil { return fmt.Errorf("failed to delete remote branch %s: %w", cs.Cyan(pr.HeadRefName), err) } } diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go index 17214915779..087a22ecbad 100644 --- a/pkg/cmd/pr/close/close_test.go +++ b/pkg/cmd/pr/close/close_test.go @@ -70,6 +70,7 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, + GitHubREST: httpmock.RESTClientFunc(rt), Branch: func() (string, error) { return "trunk", nil }, diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index e2744124535..818ec5a888f 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -25,6 +25,7 @@ import ( "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/pr/shared" + reposhared "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/markdown" @@ -1173,8 +1174,12 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { refs := ctx.PRRefs forkableRefs, requiresFork := refs.(forkableRefs) if requiresFork { + restClient, err := opts.GitHubREST(forkableRefs.BaseRepo().RepoHost()) + if err != nil { + return err + } opts.IO.StartProgressIndicator() - forkedRepo, err := api.ForkRepo(ctx.Client, forkableRefs.BaseRepo(), "", "", true) + forkedRepo, err := reposhared.ForkRepo(context.Background(), restClient, forkableRefs.BaseRepo(), "", "", true) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("error forking repo: %w", err) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 59d5e3d1364..77ea5e634bc 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -1,6 +1,7 @@ package edit import ( + "context" "fmt" "net/http" "slices" @@ -204,7 +205,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman return runF(opts) } - return editRun(opts) + return editRun(cmd.Context(), opts) }, } @@ -246,7 +247,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman return cmd } -func editRun(opts *EditOptions) error { +func editRun(ctx context.Context, opts *EditOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -360,8 +361,13 @@ func editRun(opts *EditOptions) error { } } + restClient, err := opts.GitHubREST(repo.RepoHost()) + if err != nil { + return err + } + opts.IO.StartProgressIndicator() - err = updatePullRequest(httpClient, repo, pr.ID, pr.Number, editable) + err = updatePullRequest(ctx, httpClient, restClient, repo, pr.ID, pr.Number, editable) opts.IO.StopProgressIndicator() if err != nil { return err @@ -417,20 +423,20 @@ func reviewerSearchFunc(apiClient *api.Client, repo ghrepo.Interface, editable * return searchFunc } -func updatePullRequest(httpClient *http.Client, repo ghrepo.Interface, id string, number int, editable shared.Editable) error { +func updatePullRequest(ctx context.Context, httpClient *http.Client, restClient *githubrest.Client, repo ghrepo.Interface, id string, number int, editable shared.Editable) error { var wg errgroup.Group wg.Go(func() error { return shared.UpdateIssue(httpClient, repo, id, true, editable) }) if editable.Reviewers.Edited { wg.Go(func() error { - return updatePullRequestReviews(httpClient, repo, id, number, editable) + return updatePullRequestReviews(ctx, httpClient, restClient, repo, id, number, editable) }) } return wg.Wait() } -func updatePullRequestReviews(httpClient *http.Client, repo ghrepo.Interface, prID string, number int, editable shared.Editable) error { +func updatePullRequestReviews(ctx context.Context, httpClient *http.Client, restClient *githubrest.Client, repo ghrepo.Interface, prID string, number int, editable shared.Editable) error { if !editable.Reviewers.Edited { return nil } @@ -469,7 +475,7 @@ func updatePullRequestReviews(httpClient *http.Client, repo ghrepo.Interface, pr if editable.ApiActorsSupported { return updatePullRequestReviewsGraphQL(client, repo, prID, editable) } - return updatePullRequestReviewsREST(client, repo, number, editable) + return updatePullRequestReviewsREST(ctx, restClient, repo, number, editable) } // updatePullRequestReviewsGraphQL uses the RequestReviewsByLogin mutation. @@ -481,7 +487,7 @@ func updatePullRequestReviewsGraphQL(client *api.Client, repo ghrepo.Interface, // updatePullRequestReviewsREST uses the REST API to add/remove reviewers. // This is the legacy path for GHES compatibility. -func updatePullRequestReviewsREST(client *api.Client, repo ghrepo.Interface, number int, editable shared.Editable) error { +func updatePullRequestReviewsREST(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, number int, editable shared.Editable) error { addUsers, addBots, addTeams := partitionReviewersByType(editable.Reviewers.Value) // REST API doesn't distinguish bots from users, so we need to combine them. allAddUsers := append(addUsers, addBots...) @@ -498,10 +504,10 @@ func updatePullRequestReviewsREST(client *api.Client, repo ghrepo.Interface, num wg := errgroup.Group{} wg.Go(func() error { - return api.AddPullRequestReviews(client, repo, number, allAddUsers, addTeams) + return addPullRequestReviews(ctx, client, repo, number, allAddUsers, addTeams) }) wg.Go(func() error { - return api.RemovePullRequestReviews(client, repo, number, allRemoveUsers, removeTeams) + return removePullRequestReviews(ctx, client, repo, number, allRemoveUsers, removeTeams) }) return wg.Wait() } diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index abe76415883..08eb304abb3 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -2,6 +2,7 @@ package edit import ( "bytes" + "context" "fmt" "net/http" "os" @@ -1170,9 +1171,10 @@ func Test_editRun(t *testing.T) { tt.input.IO = ios tt.input.HttpClient = httpClient + tt.input.GitHubREST = httpmock.RESTClientFunc(reg) tt.input.BaseRepo = baseRepo - err := editRun(tt.input) + err := editRun(context.Background(), tt.input) assert.NoError(t, err) assert.Equal(t, tt.stdout, stdout.String()) assert.Equal(t, tt.stderr, stderr.String()) @@ -1417,12 +1419,13 @@ func TestProjectsV1Deprecation(t *testing.T) { // Ignore the error because we have no way to really stub it without // fully stubbing a GQL error structure in the request body. - _ = editRun(&EditOptions{ + _ = editRun(context.Background(), &EditOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - Detector: &fd.EnabledDetectorMock{}, + GitHubREST: httpmock.RESTClientFunc(reg), + Detector: &fd.EnabledDetectorMock{}, Finder: shared.NewFinder(f), @@ -1448,12 +1451,13 @@ func TestProjectsV1Deprecation(t *testing.T) { // Ignore the error because we have no way to really stub it without // fully stubbing a GQL error structure in the request body. - _ = editRun(&EditOptions{ + _ = editRun(context.Background(), &EditOptions{ IO: ios, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - Detector: &fd.DisabledDetectorMock{}, + GitHubREST: httpmock.RESTClientFunc(reg), + Detector: &fd.DisabledDetectorMock{}, Finder: shared.NewFinder(f), diff --git a/pkg/cmd/pr/edit/reviewers_rest.go b/pkg/cmd/pr/edit/reviewers_rest.go new file mode 100644 index 00000000000..4e263f9fceb --- /dev/null +++ b/pkg/cmd/pr/edit/reviewers_rest.go @@ -0,0 +1,114 @@ +package edit + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" + "github.com/cli/cli/v2/internal/safeurl" +) + +// addPullRequestReviews adds the given user and team reviewers to a pull request using the REST API. +// Team identifiers can be in "org/slug" format. +func addPullRequestReviews(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { + if len(users) == 0 && len(teams) == 0 { + return nil + } + + // The API requires empty arrays instead of null values + if users == nil { + users = []string{} + } + + path, err := safeurl.JoinPath( + "repos", + repo.RepoOwner(), + repo.RepoName(), + "pulls", + strconv.Itoa(prNumber), + "requested_reviewers", + ) + if err != nil { + return err + } + body := struct { + Reviewers []string `json:"reviewers"` + TeamReviewers []string `json:"team_reviewers"` + }{ + Reviewers: users, + TeamReviewers: extractTeamSlugs(teams), + } + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(body); err != nil { + return err + } + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), buf) + if err != nil { + return err + } + // The endpoint responds with the updated pull request object; we don't need it here. + _, err = client.Do(req, nil) + return err +} + +// removePullRequestReviews removes requested reviewers from a pull request using the REST API. +// Team identifiers can be in "org/slug" format. +func removePullRequestReviews(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { + if len(users) == 0 && len(teams) == 0 { + return nil + } + + // The API requires empty arrays instead of null values + if users == nil { + users = []string{} + } + + path, err := safeurl.JoinPath( + "repos", + repo.RepoOwner(), + repo.RepoName(), + "pulls", + strconv.Itoa(prNumber), + "requested_reviewers", + ) + if err != nil { + return err + } + body := struct { + Reviewers []string `json:"reviewers"` + TeamReviewers []string `json:"team_reviewers"` + }{ + Reviewers: users, + TeamReviewers: extractTeamSlugs(teams), + } + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(body); err != nil { + return err + } + req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), buf) + if err != nil { + return err + } + // The endpoint responds with the updated pull request object; we don't need it here. + _, err = client.Do(req, nil) + return err +} + +// extractTeamSlugs extracts just the slug portion from team identifiers. +// Team identifiers can be in "org/slug" format; this returns just the slug. +func extractTeamSlugs(teams []string) []string { + slugs := make([]string, 0, len(teams)) + for _, t := range teams { + if t == "" { + continue + } + s := strings.SplitN(t, "/", 2) + slugs = append(slugs, s[len(s)-1]) + } + return slugs +} diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 3b26ab74c1f..0062fcedceb 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -26,6 +26,7 @@ type editor interface { type MergeOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) GitClient *git.Client IO *iostreams.IOStreams Branch func() (string, error) @@ -62,6 +63,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm opts := &MergeOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, GitClient: f.GitClient, Branch: f.Branch, Remotes: f.Remotes, @@ -192,6 +194,7 @@ type mergeContext struct { pr *api.PullRequest baseRepo ghrepo.Interface httpClient *http.Client + restClient *githubrest.Client opts *MergeOptions cs *iostreams.ColorScheme isTerminal bool @@ -459,8 +462,7 @@ func (m *mergeContext) deleteRemoteBranch() error { } if !m.merged { - apiClient := api.NewClientFromHTTP(m.httpClient) - err := api.BranchDeleteRemote(apiClient, m.baseRepo, m.pr.HeadRefName) + err := shared.BranchDeleteRemote(context.Background(), m.restClient, m.baseRepo, m.pr.HeadRefName) if err != nil { // Normally, the API returns 422, with the message "Reference does not exist" // when the branch has already been deleted. It also returns 404 with the same @@ -519,6 +521,11 @@ func NewMergeContext(opts *MergeOptions) (*mergeContext, error) { return nil, err } + restClient, err := opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return nil, err + } + return &mergeContext{ opts: opts, pr: pr, @@ -526,6 +533,7 @@ func NewMergeContext(opts *MergeOptions) (*mergeContext, error) { baseRepo: baseRepo, isTerminal: opts.IO.IsStdoutTTY(), httpClient: httpClient, + restClient: restClient, merged: pr.State == MergeStateStatusMerged, deleteBranch: opts.DeleteBranch, crossRepoPR: pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner(), diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index d7e02aa715a..fe6cf4569e0 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -259,6 +259,7 @@ func runCommand(rt http.RoundTripper, pm *prompter.PrompterMock, branch string, HttpClient: func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }, + GitHubREST: httpmock.RESTClientFunc(rt), Branch: func() (string, error) { return branch, nil }, @@ -1547,6 +1548,7 @@ func TestPRMergeTTY_squashEditCommitMsgAndSubject(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: tr}, nil }, + GitHubREST: httpmock.RESTClientFunc(tr), Prompter: pm, SelectorArg: "https://github.com/OWNER/REPO/pull/123", MergeStrategyEmpty: true, @@ -1685,6 +1687,7 @@ func TestMergeRun_autoMerge(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: tr}, nil }, + GitHubREST: httpmock.RESTClientFunc(tr), SelectorArg: "https://github.com/OWNER/REPO/pull/123", AutoMergeEnable: true, MergeMethod: PullRequestMergeMethodSquash, @@ -1723,6 +1726,7 @@ func TestMergeRun_autoMerge_directMerge(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: tr}, nil }, + GitHubREST: httpmock.RESTClientFunc(tr), SelectorArg: "https://github.com/OWNER/REPO/pull/123", AutoMergeEnable: true, MergeMethod: PullRequestMergeMethodMerge, @@ -1759,6 +1763,7 @@ func TestMergeRun_disableAutoMerge(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: tr}, nil }, + GitHubREST: httpmock.RESTClientFunc(tr), SelectorArg: "https://github.com/OWNER/REPO/pull/123", AutoMergeDisable: true, Finder: shared.NewMockFinder( diff --git a/pkg/cmd/pr/shared/branch.go b/pkg/cmd/pr/shared/branch.go new file mode 100644 index 00000000000..85956fe8558 --- /dev/null +++ b/pkg/cmd/pr/shared/branch.go @@ -0,0 +1,25 @@ +package shared + +import ( + "context" + "fmt" + "net/http" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" + "github.com/cli/cli/v2/internal/safeurl" +) + +// BranchDeleteRemote deletes the remote branch on the given repository. +func BranchDeleteRemote(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, branch string) error { + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "git", "refs", fmt.Sprintf("heads/%s", branch)) + if err != nil { + return err + } + req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), nil) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err +} diff --git a/pkg/cmd/pr/shared/branch_test.go b/pkg/cmd/pr/shared/branch_test.go new file mode 100644 index 00000000000..4072e879aea --- /dev/null +++ b/pkg/cmd/pr/shared/branch_test.go @@ -0,0 +1,58 @@ +package shared + +import ( + "context" + "testing" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/require" +) + +func TestBranchDeleteRemote(t *testing.T) { + var tests = []struct { + name string + branch string + httpStubs func(*httpmock.Registry) + expectError bool + }{ + { + name: "success", + branch: "owner/branch#123", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Fowner%2Fbranch%23123"), + httpmock.StatusStringResponse(204, "")) + }, + expectError: false, + }, + { + name: "error", + branch: "my-branch", + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads%2Fmy-branch"), + httpmock.StatusStringResponse(500, `{"message": "oh no"}`)) + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + repo, _ := ghrepo.FromFullName("OWNER/REPO") + + err = BranchDeleteRemote(context.Background(), client, repo, tt.branch) + if (err != nil) != tt.expectError { + t.Fatalf("unexpected result: %v", err) + } + }) + } +} diff --git a/pkg/cmd/repo/autolink/create/create.go b/pkg/cmd/repo/autolink/create/create.go index f06ad5cf969..450d455bc5b 100644 --- a/pkg/cmd/repo/autolink/create/create.go +++ b/pkg/cmd/repo/autolink/create/create.go @@ -1,6 +1,7 @@ package create import ( + "context" "fmt" "github.com/MakeNowJust/heredoc" @@ -25,7 +26,7 @@ type createOptions struct { } type AutolinkCreateClient interface { - Create(repo ghrepo.Interface, request AutolinkCreateRequest) (*shared.Autolink, error) + Create(ctx context.Context, repo ghrepo.Interface, request AutolinkCreateRequest) (*shared.Autolink, error) } func NewCmdCreate(f *cmdutil.Factory, runF func(*createOptions) error) *cobra.Command { @@ -68,12 +69,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*createOptions) error) *cobra.Co RunE: func(c *cobra.Command, args []string) error { opts.BaseRepo = f.BaseRepo - httpClient, err := f.HttpClient() - if err != nil { - return err - } - - opts.AutolinkClient = &AutolinkCreator{HTTPClient: httpClient} + opts.AutolinkClient = &AutolinkCreator{GitHubREST: f.GitHubREST} opts.KeyPrefix = args[0] opts.URLTemplate = args[1] @@ -81,7 +77,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*createOptions) error) *cobra.Co return runF(opts) } - return createRun(opts) + return createRun(c.Context(), opts) }, } @@ -90,7 +86,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*createOptions) error) *cobra.Co return cmd } -func createRun(opts *createOptions) error { +func createRun(ctx context.Context, opts *createOptions) error { repo, err := opts.BaseRepo() if err != nil { return err @@ -102,7 +98,7 @@ func createRun(opts *createOptions) error { IsAlphanumeric: !opts.Numeric, } - autolink, err := opts.AutolinkClient.Create(repo, request) + autolink, err := opts.AutolinkClient.Create(ctx, repo, request) if err != nil { return fmt.Errorf("error creating autolink: %w", err) } diff --git a/pkg/cmd/repo/autolink/create/create_test.go b/pkg/cmd/repo/autolink/create/create_test.go index fe357f2ee83..ba08784cd60 100644 --- a/pkg/cmd/repo/autolink/create/create_test.go +++ b/pkg/cmd/repo/autolink/create/create_test.go @@ -2,6 +2,7 @@ package create import ( "bytes" + "context" "fmt" "net/http" "testing" @@ -96,7 +97,7 @@ type stubAutolinkCreator struct { err error } -func (g stubAutolinkCreator) Create(repo ghrepo.Interface, request AutolinkCreateRequest) (*shared.Autolink, error) { +func (g stubAutolinkCreator) Create(_ context.Context, repo ghrepo.Interface, request AutolinkCreateRequest) (*shared.Autolink, error) { if g.err != nil { return nil, g.err } @@ -168,7 +169,7 @@ func TestCreateRun(t *testing.T) { opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } opts.AutolinkClient = &tt.stubCreator - err := createRun(opts) + err := createRun(context.Background(), opts) if tt.expectedErr != nil { require.Error(t, err) diff --git a/pkg/cmd/repo/autolink/create/http.go b/pkg/cmd/repo/autolink/create/http.go index ccff49e4fcb..185918e5123 100644 --- a/pkg/cmd/repo/autolink/create/http.go +++ b/pkg/cmd/repo/autolink/create/http.go @@ -2,11 +2,11 @@ package create import ( "bytes" + "context" "encoding/json" "errors" "net/http" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" @@ -14,7 +14,7 @@ import ( ) type AutolinkCreator struct { - HTTPClient *http.Client + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) } type AutolinkCreateRequest struct { @@ -23,7 +23,7 @@ type AutolinkCreateRequest struct { URLTemplate string `json:"url_template"` } -func (a *AutolinkCreator) Create(repo ghrepo.Interface, request AutolinkCreateRequest) (*shared.Autolink, error) { +func (a *AutolinkCreator) Create(ctx context.Context, repo ghrepo.Interface, request AutolinkCreateRequest) (*shared.Autolink, error) { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "autolinks") if err != nil { return nil, err @@ -35,12 +35,17 @@ func (a *AutolinkCreator) Create(repo ghrepo.Interface, request AutolinkCreateRe } requestBody := bytes.NewReader(requestByte) + client, err := a.GitHubREST(repo.RepoHost()) + if err != nil { + return nil, err + } + var autolink shared.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) + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), requestBody) if err != nil { + return nil, err + } + if _, err := client.Do(req, &autolink); err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) && httpErr.StatusCode == http.StatusNotFound { httpErr.Message = "Must have admin rights to Repository." diff --git a/pkg/cmd/repo/autolink/create/http_test.go b/pkg/cmd/repo/autolink/create/http_test.go index e07d74b9f2d..e5a26fdc443 100644 --- a/pkg/cmd/repo/autolink/create/http_test.go +++ b/pkg/cmd/repo/autolink/create/http_test.go @@ -1,6 +1,7 @@ package create import ( + "context" "fmt" "net/http" "testing" @@ -145,10 +146,10 @@ func TestAutolinkCreator_Create(t *testing.T) { defer reg.Verify(t) autolinkCreator := &AutolinkCreator{ - HTTPClient: &http.Client{Transport: reg}, + GitHubREST: httpmock.RESTClientFunc(reg), } - autolink, err := autolinkCreator.Create(repo, tt.req) + autolink, err := autolinkCreator.Create(context.Background(), repo, tt.req) if tt.expectErr { require.EqualError(t, err, tt.expectedErrMsg) diff --git a/pkg/cmd/repo/autolink/delete/delete.go b/pkg/cmd/repo/autolink/delete/delete.go index e5cd874fb05..97e39ba6149 100644 --- a/pkg/cmd/repo/autolink/delete/delete.go +++ b/pkg/cmd/repo/autolink/delete/delete.go @@ -1,6 +1,7 @@ package delete import ( + "context" "fmt" "github.com/cli/cli/v2/internal/browser" @@ -25,7 +26,7 @@ type deleteOptions struct { } type AutolinkDeleteClient interface { - Delete(repo ghrepo.Interface, id string) error + Delete(ctx context.Context, repo ghrepo.Interface, id string) error } func NewCmdDelete(f *cmdutil.Factory, runF func(*deleteOptions) error) *cobra.Command { @@ -43,13 +44,8 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*deleteOptions) error) *cobra.Co RunE: func(cmd *cobra.Command, args []string) error { opts.BaseRepo = f.BaseRepo - httpClient, err := f.HttpClient() - if err != nil { - return err - } - - opts.AutolinkDeleteClient = &AutolinkDeleter{HTTPClient: httpClient} - opts.AutolinkViewClient = &view.AutolinkViewer{HTTPClient: httpClient} + opts.AutolinkDeleteClient = &AutolinkDeleter{GitHubREST: f.GitHubREST} + opts.AutolinkViewClient = &view.AutolinkViewer{GitHubREST: f.GitHubREST} opts.ID = args[0] if !opts.IO.CanPrompt() && !opts.Confirmed { @@ -60,7 +56,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*deleteOptions) error) *cobra.Co return runF(opts) } - return deleteRun(opts) + return deleteRun(cmd.Context(), opts) }, } @@ -69,7 +65,7 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*deleteOptions) error) *cobra.Co return cmd } -func deleteRun(opts *deleteOptions) error { +func deleteRun(ctx context.Context, opts *deleteOptions) error { repo, err := opts.BaseRepo() if err != nil { return err @@ -78,7 +74,7 @@ func deleteRun(opts *deleteOptions) error { out := opts.IO.Out cs := opts.IO.ColorScheme() - autolink, err := opts.AutolinkViewClient.View(repo, opts.ID) + autolink, err := opts.AutolinkViewClient.View(ctx, repo, opts.ID) if err != nil { return fmt.Errorf("%s %w", cs.Red("error deleting autolink:"), err) @@ -94,7 +90,7 @@ func deleteRun(opts *deleteOptions) error { } } - err = opts.AutolinkDeleteClient.Delete(repo, opts.ID) + err = opts.AutolinkDeleteClient.Delete(ctx, repo, opts.ID) if err != nil { return err } diff --git a/pkg/cmd/repo/autolink/delete/delete_test.go b/pkg/cmd/repo/autolink/delete/delete_test.go index 7606ebe333e..d55e7a8387e 100644 --- a/pkg/cmd/repo/autolink/delete/delete_test.go +++ b/pkg/cmd/repo/autolink/delete/delete_test.go @@ -2,6 +2,7 @@ package delete import ( "bytes" + "context" "errors" "net/http" "testing" @@ -105,7 +106,7 @@ type stubAutolinkDeleter struct { err error } -func (d *stubAutolinkDeleter) Delete(repo ghrepo.Interface, id string) error { +func (d *stubAutolinkDeleter) Delete(_ context.Context, repo ghrepo.Interface, id string) error { return d.err } @@ -114,7 +115,7 @@ type stubAutolinkViewer struct { err error } -func (g stubAutolinkViewer) View(repo ghrepo.Interface, id string) (*shared.Autolink, error) { +func (g stubAutolinkViewer) View(_ context.Context, repo ghrepo.Interface, id string) (*shared.Autolink, error) { return g.autolink, g.err } @@ -319,7 +320,7 @@ func TestDeleteRun(t *testing.T) { } tt.opts.Prompter = pm - err := deleteRun(opts) + err := deleteRun(context.Background(), opts) if tt.expectedErr != nil { require.Error(t, err, "expected error but got none") diff --git a/pkg/cmd/repo/autolink/delete/http.go b/pkg/cmd/repo/autolink/delete/http.go index f73796bb5e3..869aa32fb7f 100644 --- a/pkg/cmd/repo/autolink/delete/http.go +++ b/pkg/cmd/repo/autolink/delete/http.go @@ -1,31 +1,36 @@ package delete import ( + "context" "errors" "fmt" "net/http" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) type AutolinkDeleter struct { - HTTPClient *http.Client + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) } -func (a *AutolinkDeleter) Delete(repo ghrepo.Interface, id string) error { +func (a *AutolinkDeleter) Delete(ctx context.Context, repo ghrepo.Interface, id string) error { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "autolinks", id) if err != nil { return err } - // 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) + client, err := a.GitHubREST(repo.RepoHost()) if err != nil { + return err + } + + req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), nil) + if err != nil { + return err + } + if _, err := client.Do(req, nil); err != nil { var httpErr *githubrest.ErrorResponse 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) diff --git a/pkg/cmd/repo/autolink/delete/http_test.go b/pkg/cmd/repo/autolink/delete/http_test.go index 96025ba4d25..56c7209cc76 100644 --- a/pkg/cmd/repo/autolink/delete/http_test.go +++ b/pkg/cmd/repo/autolink/delete/http_test.go @@ -1,6 +1,7 @@ package delete import ( + "context" "fmt" "net/http" "testing" @@ -64,10 +65,10 @@ func TestAutolinkDeleter_Delete(t *testing.T) { defer reg.Verify(t) autolinkDeleter := &AutolinkDeleter{ - HTTPClient: &http.Client{Transport: reg}, + GitHubREST: httpmock.RESTClientFunc(reg), } - err := autolinkDeleter.Delete(repo, tt.id) + err := autolinkDeleter.Delete(context.Background(), repo, tt.id) if tt.expectErr { require.EqualError(t, err, tt.expectedErrMsg) diff --git a/pkg/cmd/repo/autolink/list/http.go b/pkg/cmd/repo/autolink/list/http.go index ef92df5b3bf..103dad9773c 100644 --- a/pkg/cmd/repo/autolink/list/http.go +++ b/pkg/cmd/repo/autolink/list/http.go @@ -1,11 +1,11 @@ package list import ( + "context" "errors" "fmt" "net/http" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" @@ -13,21 +13,26 @@ import ( ) type AutolinkLister struct { - HTTPClient *http.Client + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) } -func (a *AutolinkLister) List(repo ghrepo.Interface) ([]shared.Autolink, error) { +func (a *AutolinkLister) List(ctx context.Context, repo ghrepo.Interface) ([]shared.Autolink, error) { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "autolinks") if err != nil { return nil, err } + client, err := a.GitHubREST(repo.RepoHost()) + if err != nil { + return nil, err + } + var autolinks []shared.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, &autolinks) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) if err != nil { + return nil, err + } + if _, err := client.Do(req, &autolinks); err != nil { var httpErr *githubrest.ErrorResponse 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) diff --git a/pkg/cmd/repo/autolink/list/http_test.go b/pkg/cmd/repo/autolink/list/http_test.go index 714719e4409..46ec98e90a5 100644 --- a/pkg/cmd/repo/autolink/list/http_test.go +++ b/pkg/cmd/repo/autolink/list/http_test.go @@ -1,6 +1,7 @@ package list import ( + "context" "fmt" "net/http" "testing" @@ -94,9 +95,9 @@ func TestAutolinkLister_List(t *testing.T) { defer reg.Verify(t) autolinkLister := &AutolinkLister{ - HTTPClient: &http.Client{Transport: reg}, + GitHubREST: httpmock.RESTClientFunc(reg), } - autolinks, err := autolinkLister.List(tt.repo) + autolinks, err := autolinkLister.List(context.Background(), tt.repo) if tt.expectedErrMsg != "" { require.EqualError(t, err, tt.expectedErrMsg) if tt.expectedStatus != 0 { diff --git a/pkg/cmd/repo/autolink/list/list.go b/pkg/cmd/repo/autolink/list/list.go index fb726d36954..f5926c2ea87 100644 --- a/pkg/cmd/repo/autolink/list/list.go +++ b/pkg/cmd/repo/autolink/list/list.go @@ -1,6 +1,7 @@ package list import ( + "context" "fmt" "strconv" @@ -26,7 +27,7 @@ type listOptions struct { } type AutolinkListClient interface { - List(repo ghrepo.Interface) ([]shared.Autolink, error) + List(ctx context.Context, repo ghrepo.Interface) ([]shared.Autolink, error) } func NewCmdList(f *cmdutil.Factory, runF func(*listOptions) error) *cobra.Command { @@ -48,17 +49,13 @@ func NewCmdList(f *cmdutil.Factory, runF func(*listOptions) error) *cobra.Comman RunE: func(cmd *cobra.Command, args []string) error { opts.BaseRepo = f.BaseRepo - httpClient, err := f.HttpClient() - if err != nil { - return err - } - opts.AutolinkClient = &AutolinkLister{HTTPClient: httpClient} + opts.AutolinkClient = &AutolinkLister{GitHubREST: f.GitHubREST} if runF != nil { return runF(opts) } - return listRun(opts) + return listRun(cmd.Context(), opts) }, } @@ -68,7 +65,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*listOptions) error) *cobra.Comman return cmd } -func listRun(opts *listOptions) error { +func listRun(ctx context.Context, opts *listOptions) error { repo, err := opts.BaseRepo() if err != nil { return err @@ -84,7 +81,7 @@ func listRun(opts *listOptions) error { return opts.Browser.Browse(autolinksListURL) } - autolinks, err := opts.AutolinkClient.List(repo) + autolinks, err := opts.AutolinkClient.List(ctx, repo) if err != nil { return err } diff --git a/pkg/cmd/repo/autolink/list/list_test.go b/pkg/cmd/repo/autolink/list/list_test.go index 81acfbbdff3..542100c2364 100644 --- a/pkg/cmd/repo/autolink/list/list_test.go +++ b/pkg/cmd/repo/autolink/list/list_test.go @@ -2,6 +2,7 @@ package list import ( "bytes" + "context" "net/http" "testing" @@ -101,7 +102,7 @@ type stubAutolinkLister struct { err error } -func (g stubAutolinkLister) List(repo ghrepo.Interface) ([]shared.Autolink, error) { +func (g stubAutolinkLister) List(_ context.Context, repo ghrepo.Interface) ([]shared.Autolink, error) { return g.autolinks, g.err } @@ -250,7 +251,7 @@ func TestListRun(t *testing.T) { opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } opts.AutolinkClient = &tt.stubLister - err := listRun(opts) + err := listRun(context.Background(), opts) if tt.expectedErr != nil { require.Error(t, err) diff --git a/pkg/cmd/repo/autolink/view/http.go b/pkg/cmd/repo/autolink/view/http.go index aa409b9a968..ea45e0b37b8 100644 --- a/pkg/cmd/repo/autolink/view/http.go +++ b/pkg/cmd/repo/autolink/view/http.go @@ -1,11 +1,11 @@ package view import ( + "context" "errors" "fmt" "net/http" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" @@ -13,21 +13,26 @@ import ( ) type AutolinkViewer struct { - HTTPClient *http.Client + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) } -func (a *AutolinkViewer) View(repo ghrepo.Interface, id string) (*shared.Autolink, error) { +func (a *AutolinkViewer) View(ctx context.Context, repo ghrepo.Interface, id string) (*shared.Autolink, error) { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "autolinks", id) if err != nil { return nil, err } + client, err := a.GitHubREST(repo.RepoHost()) + if err != nil { + return nil, err + } + var autolink shared.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) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) if err != nil { + return nil, err + } + if _, err := client.Do(req, &autolink); err != nil { var httpErr *githubrest.ErrorResponse 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) diff --git a/pkg/cmd/repo/autolink/view/http_test.go b/pkg/cmd/repo/autolink/view/http_test.go index eb5f30c8f4f..faac016502f 100644 --- a/pkg/cmd/repo/autolink/view/http_test.go +++ b/pkg/cmd/repo/autolink/view/http_test.go @@ -1,6 +1,7 @@ package view import ( + "context" "fmt" "net/http" "testing" @@ -97,10 +98,10 @@ func TestAutolinkViewer_View(t *testing.T) { defer reg.Verify(t) autolinkViewer := &AutolinkViewer{ - HTTPClient: &http.Client{Transport: reg}, + GitHubREST: httpmock.RESTClientFunc(reg), } - autolink, err := autolinkViewer.View(repo, tt.id) + autolink, err := autolinkViewer.View(context.Background(), repo, tt.id) if tt.expectErr { require.EqualError(t, err, tt.expectedErrMsg) diff --git a/pkg/cmd/repo/autolink/view/view.go b/pkg/cmd/repo/autolink/view/view.go index f146cd25b3d..79e3595b67e 100644 --- a/pkg/cmd/repo/autolink/view/view.go +++ b/pkg/cmd/repo/autolink/view/view.go @@ -1,6 +1,7 @@ package view import ( + "context" "fmt" "github.com/cli/cli/v2/internal/browser" @@ -22,7 +23,7 @@ type viewOptions struct { } type AutolinkViewClient interface { - View(repo ghrepo.Interface, id string) (*shared.Autolink, error) + View(ctx context.Context, repo ghrepo.Interface, id string) (*shared.Autolink, error) } func NewCmdView(f *cmdutil.Factory, runF func(*viewOptions) error) *cobra.Command { @@ -37,20 +38,15 @@ func NewCmdView(f *cmdutil.Factory, runF func(*viewOptions) error) *cobra.Comman Long: "View an autolink reference for a repository.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - httpClient, err := f.HttpClient() - if err != nil { - return err - } - opts.BaseRepo = f.BaseRepo opts.ID = args[0] - opts.AutolinkClient = &AutolinkViewer{HTTPClient: httpClient} + opts.AutolinkClient = &AutolinkViewer{GitHubREST: f.GitHubREST} if runF != nil { return runF(opts) } - return viewRun(opts) + return viewRun(cmd.Context(), opts) }, } @@ -59,7 +55,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*viewOptions) error) *cobra.Comman return cmd } -func viewRun(opts *viewOptions) error { +func viewRun(ctx context.Context, opts *viewOptions) error { repo, err := opts.BaseRepo() if err != nil { return err @@ -68,7 +64,7 @@ func viewRun(opts *viewOptions) error { out := opts.IO.Out cs := opts.IO.ColorScheme() - autolink, err := opts.AutolinkClient.View(repo, opts.ID) + autolink, err := opts.AutolinkClient.View(ctx, repo, opts.ID) if err != nil { return fmt.Errorf("%s %w", cs.Red("error viewing autolink:"), err) diff --git a/pkg/cmd/repo/autolink/view/view_test.go b/pkg/cmd/repo/autolink/view/view_test.go index 4192822eae2..386ad35c922 100644 --- a/pkg/cmd/repo/autolink/view/view_test.go +++ b/pkg/cmd/repo/autolink/view/view_test.go @@ -2,6 +2,7 @@ package view import ( "bytes" + "context" "net/http" "testing" @@ -101,7 +102,7 @@ type stubAutolinkViewer struct { err error } -func (g stubAutolinkViewer) View(repo ghrepo.Interface, id string) (*shared.Autolink, error) { +func (g stubAutolinkViewer) View(_ context.Context, repo ghrepo.Interface, id string) (*shared.Autolink, error) { return g.autolink, g.err } @@ -183,7 +184,7 @@ func TestViewRun(t *testing.T) { opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } opts.AutolinkClient = &tt.stubViewer - err := viewRun(opts) + err := viewRun(context.Background(), opts) if tt.expectedErr != nil { require.Error(t, err) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index be0be2dddc2..f99f7d87deb 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -19,6 +19,7 @@ import ( "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -37,6 +38,7 @@ type iprompter interface { type CreateOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) GitClient *git.Client Config func() (gh.Config, error) IO *iostreams.IOStreams @@ -69,6 +71,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts := &CreateOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, GitClient: f.GitClient, Config: f.Config, Prompter: f.Prompter, @@ -188,7 +191,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co if runF != nil { return runF(opts) } - return createRun(opts) + return createRun(cmd.Context(), opts) }, } @@ -220,16 +223,16 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co _ = cmd.Flags().MarkDeprecated("enable-wiki", "Disable wiki with `--disable-wiki`") _ = cmd.RegisterFlagCompletionFunc("gitignore", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - httpClient, err := opts.HttpClient() + cfg, err := opts.Config() if err != nil { return nil, cobra.ShellCompDirectiveError } - cfg, err := opts.Config() + hostname, _ := cfg.Authentication().DefaultHost() + restClient, err := opts.GitHubREST(hostname) if err != nil { return nil, cobra.ShellCompDirectiveError } - hostname, _ := cfg.Authentication().DefaultHost() - results, err := api.RepoGitIgnoreTemplates(httpClient, hostname) + results, err := shared.RepoGitIgnoreTemplates(cmd.Context(), restClient, hostname) if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -237,16 +240,16 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co }) _ = cmd.RegisterFlagCompletionFunc("license", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - httpClient, err := opts.HttpClient() + cfg, err := opts.Config() if err != nil { return nil, cobra.ShellCompDirectiveError } - cfg, err := opts.Config() + hostname, _ := cfg.Authentication().DefaultHost() + restClient, err := opts.GitHubREST(hostname) if err != nil { return nil, cobra.ShellCompDirectiveError } - hostname, _ := cfg.Authentication().DefaultHost() - licenses, err := api.RepoLicenses(httpClient, hostname) + licenses, err := shared.RepoLicenses(cmd.Context(), restClient, hostname) if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -260,7 +263,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co return cmd } -func createRun(opts *CreateOptions) error { +func createRun(ctx context.Context, opts *CreateOptions) error { if opts.Interactive { cfg, err := opts.Config() if err != nil { @@ -277,22 +280,22 @@ func createRun(opts *CreateOptions) error { } switch answer { case 0: - return createFromScratch(opts) + return createFromScratch(ctx, opts) case 1: - return createFromTemplate(opts) + return createFromTemplate(ctx, opts) case 2: - return createFromLocal(opts) + return createFromLocal(ctx, opts) } } if opts.Source == "" { - return createFromScratch(opts) + return createFromScratch(ctx, opts) } - return createFromLocal(opts) + return createFromLocal(ctx, opts) } // create new repo on remote host -func createFromScratch(opts *CreateOptions) error { +func createFromScratch(ctx context.Context, opts *CreateOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -306,6 +309,11 @@ func createFromScratch(opts *CreateOptions) error { host, _ := cfg.Authentication().DefaultHost() + restClient, err := opts.GitHubREST(host) + if err != nil { + return err + } + if opts.Interactive { opts.Name, opts.Description, opts.Visibility, err = interactiveRepoInfo(httpClient, host, opts.Prompter, "") if err != nil { @@ -315,11 +323,11 @@ func createFromScratch(opts *CreateOptions) error { if err != nil { return err } - opts.GitIgnoreTemplate, err = interactiveGitIgnore(httpClient, host, opts.Prompter) + opts.GitIgnoreTemplate, err = interactiveGitIgnore(ctx, restClient, host, opts.Prompter) if err != nil { return err } - opts.LicenseTemplate, err = interactiveLicense(httpClient, host, opts.Prompter) + opts.LicenseTemplate, err = interactiveLicense(ctx, restClient, host, opts.Prompter) if err != nil { return err } @@ -390,7 +398,7 @@ func createFromScratch(opts *CreateOptions) error { templateRepoMainBranch = repo.DefaultBranchRef.Name } - repo, err := repoCreate(httpClient, repoToCreate.RepoHost(), input) + repo, err := repoCreate(ctx, httpClient, restClient, repoToCreate.RepoHost(), input) if err != nil { return err } @@ -435,7 +443,7 @@ func createFromScratch(opts *CreateOptions) error { } // create new repo on remote host from template repo -func createFromTemplate(opts *CreateOptions) error { +func createFromTemplate(ctx context.Context, opts *CreateOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -448,6 +456,11 @@ func createFromTemplate(opts *CreateOptions) error { host, _ := cfg.Authentication().DefaultHost() + restClient, err := opts.GitHubREST(host) + if err != nil { + return err + } + opts.Name, opts.Description, opts.Visibility, err = interactiveRepoInfo(httpClient, host, opts.Prompter, "") if err != nil { return err @@ -499,7 +512,7 @@ func createFromTemplate(opts *CreateOptions) error { return cmdutil.CancelError } - repo, err := repoCreate(httpClient, repoToCreate.RepoHost(), input) + repo, err := repoCreate(ctx, httpClient, restClient, repoToCreate.RepoHost(), input) if err != nil { return err } @@ -530,7 +543,7 @@ func createFromTemplate(opts *CreateOptions) error { } // create repo on remote host from existing local repo -func createFromLocal(opts *CreateOptions) error { +func createFromLocal(ctx context.Context, opts *CreateOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -546,6 +559,11 @@ func createFromLocal(opts *CreateOptions) error { } host, _ := cfg.Authentication().DefaultHost() + restClient, err := opts.GitHubREST(host) + if err != nil { + return err + } + if opts.Interactive { var err error opts.Source, err = opts.Prompter.Input("Path to local repository", ".") @@ -626,7 +644,7 @@ func createFromLocal(opts *CreateOptions) error { LicenseTemplate: opts.LicenseTemplate, } - repo, err := repoCreate(httpClient, repoToCreate.RepoHost(), input) + repo, err := repoCreate(ctx, httpClient, restClient, repoToCreate.RepoHost(), input) if err != nil { return err } @@ -850,7 +868,7 @@ func interactiveRepoTemplate(client *http.Client, hostname, owner string, prompt return &templateRepos[selected], nil } -func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompter) (string, error) { +func interactiveGitIgnore(ctx context.Context, client *githubrest.Client, hostname string, prompter iprompter) (string, error) { confirmed, err := prompter.Confirm("Would you like to add a .gitignore?", false) if err != nil { return "", err @@ -858,7 +876,7 @@ func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompt return "", nil } - templates, err := api.RepoGitIgnoreTemplates(client, hostname) + templates, err := shared.RepoGitIgnoreTemplates(ctx, client, hostname) if err != nil { return "", err } @@ -869,7 +887,7 @@ func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompt return templates[selected], nil } -func interactiveLicense(client *http.Client, hostname string, prompter iprompter) (string, error) { +func interactiveLicense(ctx context.Context, client *githubrest.Client, hostname string, prompter iprompter) (string, error) { confirmed, err := prompter.Confirm("Would you like to add a license?", false) if err != nil { return "", err @@ -877,7 +895,7 @@ func interactiveLicense(client *http.Client, hostname string, prompter iprompter return "", nil } - licenses, err := api.RepoLicenses(client, hostname) + licenses, err := shared.RepoLicenses(ctx, client, hostname) if err != nil { return "", err } diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 5f1f17e604b..31d601bb7c5 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -2,6 +2,7 @@ package create import ( "bytes" + "context" "fmt" "net/http" "testing" @@ -1048,6 +1049,7 @@ func Test_createRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) if tt.opts.Config == nil { tt.opts.Config = func() (gh.Config, error) { @@ -1073,7 +1075,7 @@ func Test_createRun(t *testing.T) { } defer reg.Verify(t) - err := createRun(tt.opts) + err := createRun(context.Background(), tt.opts) if tt.wantErr { require.Error(t, err) assert.Contains(t, err.Error(), tt.errMsg) diff --git a/pkg/cmd/repo/create/http.go b/pkg/cmd/repo/create/http.go index 8a93814ca98..94c6280acd2 100644 --- a/pkg/cmd/repo/create/http.go +++ b/pkg/cmd/repo/create/http.go @@ -2,13 +2,17 @@ package create import ( "bytes" + "context" "encoding/json" "fmt" "net/http" "strings" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" + "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/shurcooL/githubv4" ) @@ -74,7 +78,7 @@ type updateRepositoryInput struct { } // repoCreate creates a new GitHub repository -func repoCreate(client *http.Client, hostname string, input repoCreateInput) (*api.Repository, error) { +func repoCreate(ctx context.Context, client *http.Client, restClient *githubrest.Client, hostname string, input repoCreateInput) (*api.Repository, error) { isOrg := false var ownerID string var teamID string @@ -83,7 +87,7 @@ func repoCreate(client *http.Client, hostname string, input repoCreateInput) (*a apiClient := api.NewClientFromHTTP(client) if input.TeamSlug != "" { - team, err := resolveOrganizationTeam(apiClient, hostname, input.OwnerLogin, input.TeamSlug) + team, err := resolveOrganizationTeam(ctx, restClient, hostname, input.OwnerLogin, input.TeamSlug) if err != nil { return nil, err } @@ -92,7 +96,7 @@ func repoCreate(client *http.Client, hostname string, input repoCreateInput) (*a ownerID = team.Organization.NodeID isOrg = true } else if input.OwnerLogin != "" { - owner, err := resolveOwner(apiClient, hostname, input.OwnerLogin) + owner, err := resolveOwner(ctx, restClient, hostname, input.OwnerLogin) if err != nil { return nil, err } @@ -205,7 +209,7 @@ func repoCreate(client *http.Client, hostname string, input repoCreateInput) (*a return nil, err } - repo, err := api.CreateRepoTransformToV4(apiClient, hostname, "POST", path, body) + repo, err := shared.CreateRepoTransformToV4(ctx, restClient, hostname, "POST", path, body) if err != nil { return nil, err } @@ -259,13 +263,17 @@ func (r *ownerResponse) IsOrganization() bool { return r.Type == "Organization" } -func resolveOwner(client *api.Client, hostname, orgName string) (*ownerResponse, error) { +func resolveOwner(ctx context.Context, client *githubrest.Client, hostname, orgName string) (*ownerResponse, error) { var response ownerResponse - u, err := safeurl.JoinPath("users", orgName) + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(hostname), "users", orgName) if err != nil { return nil, err } - err = client.REST(hostname, "GET", u.String(), nil, &response) + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + _, err = client.Do(req, &response) return &response, err } @@ -277,13 +285,17 @@ type teamResponse struct { } } -func resolveOrganizationTeam(client *api.Client, hostname, orgName, teamSlug string) (*teamResponse, error) { +func resolveOrganizationTeam(ctx context.Context, client *githubrest.Client, hostname, orgName, teamSlug string) (*teamResponse, error) { var response teamResponse - u, err := safeurl.JoinPath("orgs", orgName, "teams", teamSlug) + u, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(hostname), "orgs", orgName, "teams", teamSlug) + if err != nil { + return nil, err + } + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } - err = client.REST(hostname, "GET", u.String(), nil, &response) + _, err = client.Do(req, &response) return &response, err } diff --git a/pkg/cmd/repo/create/http_test.go b/pkg/cmd/repo/create/http_test.go index ec39b3c5048..c0450cc4ef1 100644 --- a/pkg/cmd/repo/create/http_test.go +++ b/pkg/cmd/repo/create/http_test.go @@ -1,12 +1,14 @@ package create import ( + "context" "net/http" "testing" "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 Test_repoCreate(t *testing.T) { @@ -734,7 +736,9 @@ func Test_repoCreate(t *testing.T) { defer reg.Verify(t) tt.stubs(t, reg) httpClient := &http.Client{Transport: reg} - r, err := repoCreate(httpClient, tt.hostname, tt.input) + restClient, err := httpmock.RESTClientFunc(reg)(tt.hostname) + require.NoError(t, err) + r, err := repoCreate(context.Background(), httpClient, restClient, tt.hostname, tt.input) if tt.wantErr { assert.Error(t, err) if tt.errMsg != "" { diff --git a/pkg/cmd/repo/credits/credits.go b/pkg/cmd/repo/credits/credits.go index 42c5766d7ed..19f040cb11b 100644 --- a/pkg/cmd/repo/credits/credits.go +++ b/pkg/cmd/repo/credits/credits.go @@ -2,6 +2,7 @@ package credits import ( "bytes" + "context" "fmt" "math" "math/rand" @@ -13,8 +14,8 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -24,6 +25,7 @@ import ( type CreditsOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) BaseRepo func() (ghrepo.Interface, error) IO *iostreams.IOStreams @@ -34,6 +36,7 @@ type CreditsOptions struct { func NewCmdCredits(f *cmdutil.Factory, runF func(*CreditsOptions) error) *cobra.Command { opts := &CreditsOptions{ HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, IO: f.IOStreams, BaseRepo: f.BaseRepo, Repository: "cli/cli", @@ -59,7 +62,7 @@ func NewCmdCredits(f *cmdutil.Factory, runF func(*CreditsOptions) error) *cobra. return runF(opts) } - return creditsRun(opts) + return creditsRun(cmd.Context(), opts) }, Hidden: true, } @@ -72,6 +75,7 @@ func NewCmdCredits(f *cmdutil.Factory, runF func(*CreditsOptions) error) *cobra. func NewCmdRepoCredits(f *cmdutil.Factory, runF func(*CreditsOptions) error) *cobra.Command { opts := &CreditsOptions{ HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, BaseRepo: f.BaseRepo, IO: f.IOStreams, } @@ -102,7 +106,7 @@ func NewCmdRepoCredits(f *cmdutil.Factory, runF func(*CreditsOptions) error) *co return runF(opts) } - return creditsRun(opts) + return creditsRun(cmd.Context(), opts) }, Hidden: true, } @@ -112,16 +116,11 @@ func NewCmdRepoCredits(f *cmdutil.Factory, runF func(*CreditsOptions) error) *co return cmd } -func creditsRun(opts *CreditsOptions) error { +func creditsRun(ctx context.Context, opts *CreditsOptions) error { isWindows := runtime.GOOS == "windows" - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - - client := api.NewClientFromHTTP(httpClient) var baseRepo ghrepo.Interface + var err error if opts.Repository == "" { baseRepo, err = opts.BaseRepo() if err != nil { @@ -134,6 +133,11 @@ func creditsRun(opts *CreditsOptions) error { } } + client, err := opts.GitHubREST(baseRepo.RepoHost()) + if err != nil { + return err + } + type Contributor struct { Login string Type string @@ -148,10 +152,13 @@ func creditsRun(opts *CreditsOptions) error { return err } - err = client.REST(baseRepo.RepoHost(), "GET", path.String(), body, &result) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), body) if err != nil { return err } + if _, err := client.Do(req, &result); err != nil { + return err + } isTTY := opts.IO.IsStdoutTTY() diff --git a/pkg/cmd/repo/deploy-key/add/add.go b/pkg/cmd/repo/deploy-key/add/add.go index 2466ec98abd..1f43b51d949 100644 --- a/pkg/cmd/repo/deploy-key/add/add.go +++ b/pkg/cmd/repo/deploy-key/add/add.go @@ -1,13 +1,14 @@ package add import ( + "context" "fmt" "io" - "net/http" "os" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -15,7 +16,7 @@ import ( type AddOptions struct { IO *iostreams.IOStreams - HTTPClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) BaseRepo func() (ghrepo.Interface, error) KeyFile string @@ -25,7 +26,7 @@ type AddOptions struct { func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command { opts := &AddOptions{ - HTTPClient: f.HttpClient, + GitHubREST: f.GitHubREST, IO: f.IOStreams, } @@ -52,7 +53,7 @@ func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command if runF != nil { return runF(opts) } - return addRun(opts) + return addRun(cmd.Context(), opts) }, } @@ -61,12 +62,7 @@ func NewCmdAdd(f *cmdutil.Factory, runF func(*AddOptions) error) *cobra.Command return cmd } -func addRun(opts *AddOptions) error { - httpClient, err := opts.HTTPClient() - if err != nil { - return err - } - +func addRun(ctx context.Context, opts *AddOptions) error { var keyReader io.Reader if opts.KeyFile == "-" { keyReader = opts.IO.In @@ -85,7 +81,12 @@ func addRun(opts *AddOptions) error { return err } - if err := uploadDeployKey(httpClient, repo, keyReader, opts.Title, opts.AllowWrite); err != nil { + client, err := opts.GitHubREST(repo.RepoHost()) + if err != nil { + return err + } + + if err := uploadDeployKey(ctx, client, repo, keyReader, opts.Title, opts.AllowWrite); err != nil { return err } diff --git a/pkg/cmd/repo/deploy-key/add/add_test.go b/pkg/cmd/repo/deploy-key/add/add_test.go index 1b68a691435..f144a3da5c7 100644 --- a/pkg/cmd/repo/deploy-key/add/add_test.go +++ b/pkg/cmd/repo/deploy-key/add/add_test.go @@ -1,6 +1,7 @@ package add import ( + "context" "net/http" "strings" "testing" @@ -70,9 +71,9 @@ func Test_addRun(t *testing.T) { opts := tt.opts opts.IO = ios opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } - opts.HTTPClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + opts.GitHubREST = httpmock.RESTClientFunc(reg) - err := addRun(&opts) + err := addRun(context.Background(), &opts) if (err != nil) != tt.wantErr { t.Errorf("addRun() return error: %v", err) return @@ -97,8 +98,12 @@ func TestUploadDeployKeyHTTPError(t *testing.T) { httpmock.StatusStringResponse(http.StatusNotFound, `{"message":"Not Found"}`), ) - err := uploadDeployKey( - &http.Client{Transport: reg}, + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + + err = uploadDeployKey( + context.Background(), + client, ghrepo.New("OWNER", "REPO"), strings.NewReader("PUBKEY\n"), "my sacred key", diff --git a/pkg/cmd/repo/deploy-key/add/http.go b/pkg/cmd/repo/deploy-key/add/http.go index c8134d965e4..0ebef32e3d3 100644 --- a/pkg/cmd/repo/deploy-key/add/http.go +++ b/pkg/cmd/repo/deploy-key/add/http.go @@ -2,16 +2,17 @@ package add import ( "bytes" + "context" "encoding/json" "io" "net/http" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) -func uploadDeployKey(httpClient *http.Client, repo ghrepo.Interface, keyFile io.Reader, title string, isWritable bool) error { +func uploadDeployKey(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, keyFile io.Reader, title string, isWritable bool) error { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "keys") if err != nil { return err @@ -33,8 +34,10 @@ func uploadDeployKey(httpClient *http.Client, repo ghrepo.Interface, keyFile io. return err } - // 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(repo.RepoHost(), "POST", path.String(), bytes.NewBuffer(payloadBytes), nil) + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), bytes.NewBuffer(payloadBytes)) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err } diff --git a/pkg/cmd/repo/deploy-key/delete/delete.go b/pkg/cmd/repo/deploy-key/delete/delete.go index a58b8957c75..8aaca8e456c 100644 --- a/pkg/cmd/repo/deploy-key/delete/delete.go +++ b/pkg/cmd/repo/deploy-key/delete/delete.go @@ -1,10 +1,11 @@ package delete import ( + "context" "fmt" - "net/http" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -12,7 +13,7 @@ import ( type DeleteOptions struct { IO *iostreams.IOStreams - HTTPClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) BaseRepo func() (ghrepo.Interface, error) KeyID string @@ -20,7 +21,7 @@ type DeleteOptions struct { func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { opts := &DeleteOptions{ - HTTPClient: f.HttpClient, + GitHubREST: f.GitHubREST, IO: f.IOStreams, } @@ -35,25 +36,25 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co if runF != nil { return runF(opts) } - return deleteRun(opts) + return deleteRun(cmd.Context(), opts) }, } return cmd } -func deleteRun(opts *DeleteOptions) error { - httpClient, err := opts.HTTPClient() +func deleteRun(ctx context.Context, opts *DeleteOptions) error { + repo, err := opts.BaseRepo() if err != nil { return err } - repo, err := opts.BaseRepo() + client, err := opts.GitHubREST(repo.RepoHost()) if err != nil { return err } - if err := deleteDeployKey(httpClient, repo, opts.KeyID); err != nil { + if err := deleteDeployKey(ctx, client, repo, opts.KeyID); err != nil { return err } diff --git a/pkg/cmd/repo/deploy-key/delete/delete_test.go b/pkg/cmd/repo/deploy-key/delete/delete_test.go index 2521ecd82f4..856864d8f23 100644 --- a/pkg/cmd/repo/deploy-key/delete/delete_test.go +++ b/pkg/cmd/repo/deploy-key/delete/delete_test.go @@ -1,6 +1,7 @@ package delete import ( + "context" "net/http" "testing" @@ -25,11 +26,9 @@ func Test_deleteRun(t *testing.T) { httpmock.REST("DELETE", "repos/OWNER/REPO/keys/1234"), httpmock.StringResponse(`{}`)) - err := deleteRun(&DeleteOptions{ - IO: ios, - HTTPClient: func() (*http.Client, error) { - return &http.Client{Transport: &tr}, nil - }, + err := deleteRun(context.Background(), &DeleteOptions{ + IO: ios, + GitHubREST: httpmock.RESTClientFunc(&tr), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -50,8 +49,12 @@ func TestDeleteDeployKeyHTTPError(t *testing.T) { httpmock.StatusStringResponse(http.StatusNotFound, `{"message":"Not Found"}`), ) - err := deleteDeployKey( - &http.Client{Transport: reg}, + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + + err = deleteDeployKey( + context.Background(), + client, ghrepo.New("OWNER", "REPO"), "1234", ) diff --git a/pkg/cmd/repo/deploy-key/delete/http.go b/pkg/cmd/repo/deploy-key/delete/http.go index e88e4c28123..528e50c9989 100644 --- a/pkg/cmd/repo/deploy-key/delete/http.go +++ b/pkg/cmd/repo/deploy-key/delete/http.go @@ -1,20 +1,23 @@ package delete import ( + "context" "net/http" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) -func deleteDeployKey(httpClient *http.Client, repo ghrepo.Interface, id string) error { +func deleteDeployKey(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, id string) error { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "keys", id) if err != nil { return err } - // 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(repo.RepoHost(), "DELETE", path.String(), nil, nil) + req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), nil) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err } diff --git a/pkg/cmd/repo/deploy-key/list/http.go b/pkg/cmd/repo/deploy-key/list/http.go index 02f45eb66ac..3977d1096ee 100644 --- a/pkg/cmd/repo/deploy-key/list/http.go +++ b/pkg/cmd/repo/deploy-key/list/http.go @@ -1,11 +1,12 @@ package list import ( + "context" "net/http" "time" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -17,7 +18,7 @@ type deployKey struct { ReadOnly bool `json:"read_only"` } -func repoKeys(httpClient *http.Client, repo ghrepo.Interface) ([]deployKey, error) { +func repoKeys(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface) ([]deployKey, error) { u, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "keys") if err != nil { return nil, err @@ -25,13 +26,13 @@ func repoKeys(httpClient *http.Client, repo ghrepo.Interface) ([]deployKey, erro u.SetQuery("per_page", "100") var keys []deployKey - // 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(), "GET", u.String(), nil, &keys) + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } + if _, err := client.Do(req, &keys); err != nil { + return nil, err + } return keys, nil } diff --git a/pkg/cmd/repo/deploy-key/list/list.go b/pkg/cmd/repo/deploy-key/list/list.go index b0475ddf24b..aac7239de60 100644 --- a/pkg/cmd/repo/deploy-key/list/list.go +++ b/pkg/cmd/repo/deploy-key/list/list.go @@ -1,12 +1,13 @@ package list import ( + "context" "fmt" - "net/http" "strconv" "time" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -15,7 +16,7 @@ import ( type ListOptions struct { IO *iostreams.IOStreams - HTTPClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) BaseRepo func() (ghrepo.Interface, error) Exporter cmdutil.Exporter } @@ -31,7 +32,7 @@ var deployKeyFields = []string{ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ IO: f.IOStreams, - HTTPClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -45,25 +46,25 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return listRun(opts) + return listRun(cmd.Context(), opts) }, } cmdutil.AddJSONFlags(cmd, &opts.Exporter, deployKeyFields) return cmd } -func listRun(opts *ListOptions) error { - apiClient, err := opts.HTTPClient() +func listRun(ctx context.Context, opts *ListOptions) error { + repo, err := opts.BaseRepo() if err != nil { return err } - repo, err := opts.BaseRepo() + client, err := opts.GitHubREST(repo.RepoHost()) if err != nil { return err } - deployKeys, err := repoKeys(apiClient, repo) + deployKeys, err := repoKeys(ctx, client, repo) if err != nil { return err } diff --git a/pkg/cmd/repo/deploy-key/list/list_test.go b/pkg/cmd/repo/deploy-key/list/list_test.go index 1c87c1979b3..1af6fcce693 100644 --- a/pkg/cmd/repo/deploy-key/list/list_test.go +++ b/pkg/cmd/repo/deploy-key/list/list_test.go @@ -1,6 +1,7 @@ package list import ( + "context" "fmt" "net/http" "testing" @@ -117,9 +118,9 @@ func TestListRun(t *testing.T) { opts := tt.opts opts.IO = ios opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } - opts.HTTPClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + opts.GitHubREST = httpmock.RESTClientFunc(reg) - err := listRun(&opts) + err := listRun(context.Background(), &opts) if (err != nil) != tt.wantErr { t.Errorf("listRun() return error: %v", err) return @@ -144,8 +145,12 @@ func TestRepoKeysHTTPError(t *testing.T) { httpmock.StatusStringResponse(http.StatusNotFound, `{"message":"Not Found"}`), ) - _, err := repoKeys( - &http.Client{Transport: reg}, + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + + _, err = repoKeys( + context.Background(), + client, ghrepo.New("OWNER", "REPO"), ) diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index 788b52f6ef4..4d503149b19 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -5,7 +5,6 @@ import ( "context" "encoding/json" "fmt" - "io" "net/http" "slices" "strings" @@ -19,6 +18,7 @@ import ( "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" + reposhared "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/set" @@ -317,12 +317,16 @@ func editRun(ctx context.Context, opts *EditOptions) error { return err } + restClient, err := opts.GitHubREST(repo.RepoHost()) + if err != nil { + return err + } + g := errgroup.Group{} if body.Len() > 3 { g.Go(func() error { - apiClient := api.NewClientFromHTTP(opts.HTTPClient) - _, err := api.CreateRepoTransformToV4(apiClient, repo.RepoHost(), "PATCH", apiPath, body) + _, err := reposhared.CreateRepoTransformToV4(ctx, restClient, repo.RepoHost(), "PATCH", apiPath, body) return err }) } @@ -332,7 +336,7 @@ func editRun(ctx context.Context, opts *EditOptions) error { // opts.topicsCache gets populated in interactive mode if !opts.InteractiveMode { var err error - opts.topicsCache, err = getTopics(ctx, opts.HTTPClient, repo) + opts.topicsCache, err = getTopics(ctx, restClient, repo) if err != nil { return err } @@ -348,7 +352,7 @@ func editRun(ctx context.Context, opts *EditOptions) error { if oldTopics.Equal(newTopics) { return nil } - return setTopics(ctx, opts.HTTPClient, repo, newTopics.ToSlice()) + return setTopics(ctx, restClient, repo, newTopics.ToSlice()) }) } @@ -573,37 +577,28 @@ func parseTopics(s string) []string { return topics } -func getTopics(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface) ([]string, error) { +func getTopics(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface) ([]string, error) { url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName(), "topics") if err != nil { return nil, err } - req, err := http.NewRequestWithContext(ctx, "GET", url.String(), nil) - if err != nil { - return nil, err - } // "mercy-preview" is still needed for some GitHub Enterprise versions - req.Header.Set("Accept", "application/vnd.github.mercy-preview+json") - res, err := httpClient.Do(req) + req, err := client.NewRequest(ctx, http.MethodGet, url.String(), nil, githubrest.WithHeader("Accept", "application/vnd.github.mercy-preview+json")) if err != nil { return nil, err } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return nil, api.HandleHTTPError(res) - } var responseData struct { Names []string `json:"names"` } - dec := json.NewDecoder(res.Body) - err = dec.Decode(&responseData) - return responseData.Names, err + if _, err := client.Do(req, &responseData); err != nil { + return nil, err + } + return responseData.Names, nil } -func setTopics(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface, topics []string) error { +func setTopics(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, topics []string) error { payload := struct { Names []string `json:"names"` }{ @@ -619,26 +614,18 @@ func setTopics(ctx context.Context, httpClient *http.Client, repo ghrepo.Interfa if err != nil { return err } - req, err := http.NewRequestWithContext(ctx, "PUT", url.String(), body) - if err != nil { - return err - } - req.Header.Set("Content-type", "application/json") // "mercy-preview" is still needed for some GitHub Enterprise versions - req.Header.Set("Accept", "application/vnd.github.mercy-preview+json") - res, err := httpClient.Do(req) + req, err := client.NewRequest(ctx, http.MethodPut, url.String(), body, + githubrest.WithHeader("Content-type", "application/json"), + githubrest.WithHeader("Accept", "application/vnd.github.mercy-preview+json"), + ) if err != nil { return err } - defer res.Body.Close() - - if res.StatusCode != http.StatusOK { - return api.HandleHTTPError(res) - } - if res.Body != nil { - _, _ = io.Copy(io.Discard, res.Body) + if _, err := client.Do(req, nil); err != nil { + return err } return nil diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index e1e6a335183..d6f3b7c9b5c 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -4,18 +4,17 @@ import ( "context" "errors" "fmt" - "net/http" "net/url" "strings" "time" "github.com/MakeNowJust/heredoc" "github.com/cenkalti/backoff/v4" - "github.com/cli/cli/v2/api" ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -30,7 +29,7 @@ type iprompter interface { } type ForkOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) GitClient *git.Client Config func() (gh.Config, error) IO *iostreams.IOStreams @@ -64,7 +63,7 @@ type errWithExitCode interface { func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Command { opts := &ForkOptions{ IO: f.IOStreams, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, GitClient: f.GitClient, Config: f.Config, BaseRepo: f.BaseRepo, @@ -136,7 +135,7 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return forkRun(opts) + return forkRun(cmd.Context(), opts) }, } cmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { @@ -156,7 +155,7 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman return cmd } -func forkRun(opts *ForkOptions) error { +func forkRun(ctx context.Context, opts *ForkOptions) error { var repoToFork ghrepo.Interface var err error inParent := false // whether or not we're forking the repo we're currently "in" @@ -203,15 +202,13 @@ func forkRun(opts *ForkOptions) error { cs := opts.IO.ColorScheme() stderr := opts.IO.ErrOut - httpClient, err := opts.HttpClient() + restClient, err := opts.GitHubREST(repoToFork.RepoHost()) if err != nil { return fmt.Errorf("unable to create client: %w", err) } - apiClient := api.NewClientFromHTTP(httpClient) - opts.IO.StartProgressIndicator() - forkedRepo, err := api.ForkRepo(apiClient, repoToFork, opts.Organization, opts.ForkName, opts.DefaultBranchOnly) + forkedRepo, err := shared.ForkRepo(ctx, restClient, repoToFork, opts.Organization, opts.ForkName, opts.DefaultBranchOnly) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to fork: %w", err) @@ -242,7 +239,7 @@ func forkRun(opts *ForkOptions) error { // Rename the new repo if necessary if opts.ForkName != "" && !strings.EqualFold(forkedRepo.RepoName(), shared.NormalizeRepoName(opts.ForkName)) { - forkedRepo, err = api.RenameRepo(apiClient, forkedRepo, opts.ForkName) + forkedRepo, err = shared.RenameRepo(ctx, restClient, forkedRepo, opts.ForkName) if err != nil { return fmt.Errorf("could not rename fork: %w", err) } @@ -264,7 +261,6 @@ func forkRun(opts *ForkOptions) error { protocol := protocolConfig.Value gitClient := opts.GitClient - ctx := context.Background() if inParent { remotes, err := opts.Remotes() diff --git a/pkg/cmd/repo/fork/fork_test.go b/pkg/cmd/repo/fork/fork_test.go index edf5f2763b9..82acbe2c120 100644 --- a/pkg/cmd/repo/fork/fork_test.go +++ b/pkg/cmd/repo/fork/fork_test.go @@ -9,8 +9,10 @@ import ( "testing" "time" + "context" "github.com/cenkalti/backoff/v4" - "github.com/cli/cli/v2/context" + + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" @@ -219,7 +221,7 @@ func TestRepoFork(t *testing.T) { execStubs func(*run.CommandStubber) promptStubs func(*prompter.MockPrompter) cfgStubs func(*testing.T, gh.Config) - remotes []*context.Remote + remotes []*ghContext.Remote wantOut string wantErrOut string wantErr bool @@ -232,7 +234,7 @@ func TestRepoFork(t *testing.T) { Remote: true, RemoteName: "fork", }, - remotes: []*context.Remote{ + remotes: []*ghContext.Remote{ { Remote: &git.Remote{Name: "origin", PushURL: &url.URL{ Scheme: "ssh", @@ -256,7 +258,7 @@ func TestRepoFork(t *testing.T) { Remote: true, RemoteName: "fork", }, - remotes: []*context.Remote{ + remotes: []*ghContext.Remote{ { Remote: &git.Remote{Name: "origin", PushURL: &url.URL{ Scheme: "ssh", @@ -313,7 +315,7 @@ func TestRepoFork(t *testing.T) { Remote: true, RemoteName: defaultRemoteName, }, - remotes: []*context.Remote{ + remotes: []*ghContext.Remote{ { Remote: &git.Remote{Name: "origin", FetchURL: &url.URL{}}, Repo: ghrepo.New("someone", "REPO"), @@ -385,7 +387,7 @@ func TestRepoFork(t *testing.T) { RemoteName: defaultRemoteName, Rename: true, }, - remotes: []*context.Remote{ + remotes: []*ghContext.Remote{ { Remote: &git.Remote{Name: "origin", FetchURL: &url.URL{}}, Repo: ghrepo.New("someone", "REPO"), @@ -741,9 +743,7 @@ func TestRepoFork(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) cfg, _ := config.NewIsolatedTestConfig(t) if tt.cfgStubs != nil { @@ -753,9 +753,9 @@ func TestRepoFork(t *testing.T) { return cfg, nil } - tt.opts.Remotes = func() (context.Remotes, error) { + tt.opts.Remotes = func() (ghContext.Remotes, error) { if tt.remotes == nil { - return []*context.Remote{ + return []*ghContext.Remote{ { Remote: &git.Remote{ Name: "origin", @@ -792,7 +792,7 @@ func TestRepoFork(t *testing.T) { } defer reg.Verify(t) - err := forkRun(tt.opts) + err := forkRun(context.Background(), tt.opts) if tt.wantErr { assert.Error(t, err, tt.errMsg) return diff --git a/pkg/cmd/repo/gitignore/list/list.go b/pkg/cmd/repo/gitignore/list/list.go index 89dbefaf315..981fce2e408 100644 --- a/pkg/cmd/repo/gitignore/list/list.go +++ b/pkg/cmd/repo/gitignore/list/list.go @@ -1,12 +1,13 @@ package list import ( + "context" "fmt" - "net/http" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -14,14 +15,14 @@ import ( type ListOptions struct { IO *iostreams.IOStreams - HTTPClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Config func() (gh.Config, error) } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ IO: f.IOStreams, - HTTPClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, } @@ -35,18 +36,13 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return listRun(opts) + return listRun(cmd.Context(), opts) }, } return cmd } -func listRun(opts *ListOptions) error { - client, err := opts.HTTPClient() - if err != nil { - return err - } - +func listRun(ctx context.Context, opts *ListOptions) error { cfg, err := opts.Config() if err != nil { return err @@ -58,7 +54,13 @@ func listRun(opts *ListOptions) error { defer opts.IO.StopPager() hostname, _ := cfg.Authentication().DefaultHost() - gitIgnoreTemplates, err := api.RepoGitIgnoreTemplates(client, hostname) + + client, err := opts.GitHubREST(hostname) + if err != nil { + return err + } + + gitIgnoreTemplates, err := shared.RepoGitIgnoreTemplates(ctx, client, hostname) if err != nil { return err } diff --git a/pkg/cmd/repo/gitignore/list/list_test.go b/pkg/cmd/repo/gitignore/list/list_test.go index 3a68ab511f4..a1b1d77a82c 100644 --- a/pkg/cmd/repo/gitignore/list/list_test.go +++ b/pkg/cmd/repo/gitignore/list/list_test.go @@ -2,7 +2,7 @@ package list import ( "bytes" - "net/http" + "context" "testing" "github.com/MakeNowJust/heredoc" @@ -164,9 +164,7 @@ func TestListRun(t *testing.T) { tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } - tt.opts.HTTPClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.isTTY) ios.SetStdinTTY(tt.isTTY) @@ -175,7 +173,7 @@ func TestListRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { defer reg.Verify(t) - err := listRun(tt.opts) + err := listRun(context.Background(), tt.opts) if tt.wantErr { assert.Error(t, err) diff --git a/pkg/cmd/repo/gitignore/view/view.go b/pkg/cmd/repo/gitignore/view/view.go index 3340b29cc7e..01ae4f26691 100644 --- a/pkg/cmd/repo/gitignore/view/view.go +++ b/pkg/cmd/repo/gitignore/view/view.go @@ -1,15 +1,16 @@ package view import ( + "context" "errors" "fmt" - "net/http" "strings" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/githubrest" + "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -17,7 +18,7 @@ import ( type ViewOptions struct { IO *iostreams.IOStreams - HTTPClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Config func() (gh.Config, error) Template string } @@ -25,7 +26,7 @@ type ViewOptions struct { func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { opts := &ViewOptions{ IO: f.IOStreams, - HTTPClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, Template: "", } @@ -59,22 +60,17 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return viewRun(opts) + return viewRun(cmd.Context(), opts) }, } return cmd } -func viewRun(opts *ViewOptions) error { +func viewRun(ctx context.Context, opts *ViewOptions) error { if opts.Template == "" { return errors.New("no template provided") } - client, err := opts.HTTPClient() - if err != nil { - return err - } - cfg, err := opts.Config() if err != nil { return err @@ -86,7 +82,13 @@ func viewRun(opts *ViewOptions) error { defer opts.IO.StopPager() hostname, _ := cfg.Authentication().DefaultHost() - gitIgnore, err := api.RepoGitIgnoreTemplate(client, hostname, opts.Template) + + client, err := opts.GitHubREST(hostname) + if err != nil { + return err + } + + gitIgnore, err := shared.RepoGitIgnoreTemplate(ctx, client, hostname, opts.Template) if err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { diff --git a/pkg/cmd/repo/gitignore/view/view_test.go b/pkg/cmd/repo/gitignore/view/view_test.go index 3ab1bb25b36..5d374abd30e 100644 --- a/pkg/cmd/repo/gitignore/view/view_test.go +++ b/pkg/cmd/repo/gitignore/view/view_test.go @@ -2,7 +2,7 @@ package view import ( "bytes" - "net/http" + "context" "testing" "github.com/MakeNowJust/heredoc" @@ -97,9 +97,7 @@ func TestViewRun(t *testing.T) { tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } - tt.opts.HTTPClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.isTTY) ios.SetStdinTTY(tt.isTTY) @@ -108,7 +106,7 @@ func TestViewRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { defer reg.Verify(t) - err := viewRun(tt.opts) + err := viewRun(context.Background(), tt.opts) if tt.wantErr { assert.Error(t, err) diff --git a/pkg/cmd/repo/license/list/list.go b/pkg/cmd/repo/license/list/list.go index ec003ad2130..54766b8470f 100644 --- a/pkg/cmd/repo/license/list/list.go +++ b/pkg/cmd/repo/license/list/list.go @@ -1,13 +1,15 @@ package list import ( + "context" "fmt" - "net/http" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" + "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -15,14 +17,14 @@ import ( type ListOptions struct { IO *iostreams.IOStreams - HTTPClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Config func() (gh.Config, error) } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { opts := &ListOptions{ IO: f.IOStreams, - HTTPClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, } @@ -41,18 +43,13 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return listRun(opts) + return listRun(cmd.Context(), opts) }, } return cmd } -func listRun(opts *ListOptions) error { - client, err := opts.HTTPClient() - if err != nil { - return err - } - +func listRun(ctx context.Context, opts *ListOptions) error { cfg, err := opts.Config() if err != nil { return err @@ -64,7 +61,13 @@ func listRun(opts *ListOptions) error { defer opts.IO.StopPager() hostname, _ := cfg.Authentication().DefaultHost() - licenses, err := api.RepoLicenses(client, hostname) + + client, err := opts.GitHubREST(hostname) + if err != nil { + return err + } + + licenses, err := shared.RepoLicenses(ctx, client, hostname) if err != nil { return err } diff --git a/pkg/cmd/repo/license/list/list_test.go b/pkg/cmd/repo/license/list/list_test.go index 1fee996c64b..f5c2cc9b528 100644 --- a/pkg/cmd/repo/license/list/list_test.go +++ b/pkg/cmd/repo/license/list/list_test.go @@ -2,7 +2,7 @@ package list import ( "bytes" - "net/http" + "context" "testing" "github.com/MakeNowJust/heredoc" @@ -170,9 +170,7 @@ func TestListRun(t *testing.T) { tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } - tt.opts.HTTPClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.isTTY) ios.SetStdinTTY(tt.isTTY) @@ -181,7 +179,7 @@ func TestListRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { defer reg.Verify(t) - err := listRun(tt.opts) + err := listRun(context.Background(), tt.opts) if tt.wantErr { assert.Error(t, err) diff --git a/pkg/cmd/repo/license/view/view.go b/pkg/cmd/repo/license/view/view.go index 87f6e9ae0f4..73fa0e34e9a 100644 --- a/pkg/cmd/repo/license/view/view.go +++ b/pkg/cmd/repo/license/view/view.go @@ -1,9 +1,9 @@ package view import ( + "context" "errors" "fmt" - "net/http" "strings" "github.com/MakeNowJust/heredoc" @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/text" + "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -19,7 +20,7 @@ import ( type ViewOptions struct { IO *iostreams.IOStreams - HTTPClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Config func() (gh.Config, error) License string Web bool @@ -29,7 +30,7 @@ type ViewOptions struct { func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { opts := &ViewOptions{ IO: f.IOStreams, - HTTPClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, Browser: f.Browser, } @@ -64,7 +65,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman if runF != nil { return runF(opts) } - return viewRun(opts) + return viewRun(cmd.Context(), opts) }, } @@ -73,16 +74,11 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman return cmd } -func viewRun(opts *ViewOptions) error { +func viewRun(ctx context.Context, opts *ViewOptions) error { if opts.License == "" { return errors.New("no license provided") } - client, err := opts.HTTPClient() - if err != nil { - return err - } - cfg, err := opts.Config() if err != nil { return err @@ -94,7 +90,13 @@ func viewRun(opts *ViewOptions) error { defer opts.IO.StopPager() hostname, _ := cfg.Authentication().DefaultHost() - license, err := api.RepoLicense(client, hostname, opts.License) + + client, err := opts.GitHubREST(hostname) + if err != nil { + return err + } + + license, err := shared.RepoLicense(ctx, client, hostname, opts.License) if err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { diff --git a/pkg/cmd/repo/license/view/view_test.go b/pkg/cmd/repo/license/view/view_test.go index 0a282693d74..a1050435361 100644 --- a/pkg/cmd/repo/license/view/view_test.go +++ b/pkg/cmd/repo/license/view/view_test.go @@ -2,7 +2,7 @@ package view import ( "bytes" - "net/http" + "context" "testing" "github.com/MakeNowJust/heredoc" @@ -282,9 +282,7 @@ func TestViewRun(t *testing.T) { tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } - tt.opts.HTTPClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, stderr := iostreams.Test() ios.SetStdoutTTY(tt.isTTY) ios.SetStdinTTY(tt.isTTY) @@ -296,7 +294,7 @@ func TestViewRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { defer reg.Verify(t) - err := viewRun(tt.opts) + err := viewRun(context.Background(), tt.opts) if tt.wantErr { assert.Error(t, err) diff --git a/pkg/cmd/repo/read-file/http.go b/pkg/cmd/repo/read-file/http.go index 20c1ed0e9f1..c5f8cc37a26 100644 --- a/pkg/cmd/repo/read-file/http.go +++ b/pkg/cmd/repo/read-file/http.go @@ -1,16 +1,16 @@ package readfile import ( + "bytes" + "context" "encoding/base64" - "encoding/json" "fmt" - "io" "net/http" "strings" - "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/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) @@ -82,38 +82,22 @@ type contentsResponse struct { // // It requests the unified object media type so directories, files, symlinks, and // submodules all come back as a single JSON object distinguished by the type field. -func fetchContent(httpClient *http.Client, repo ghrepo.Interface, filePath, ref string) (*contentsResponse, error) { +func fetchContent(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, filePath, ref string) (*contentsResponse, error) { apiPath, err := contentsAPIPath(repo, filePath, ref) if err != nil { return nil, err } - req, err := http.NewRequest("GET", apiPath.String(), nil) - if err != nil { - return nil, err - } // We use the "application/vnd.github.object+json" media type to request a unified object // representation from the Contents API. Without this, the API returns a JSON array for // directories and a JSON object for files. - req.Header.Set("Accept", "application/vnd.github.object+json") - - resp, err := httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode > 299 { - return nil, api.HandleHTTPError(resp) - } - - body, err := io.ReadAll(resp.Body) + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil, githubrest.WithHeader("Accept", "application/vnd.github.object+json")) if err != nil { return nil, err } var content contentsResponse - if err := json.Unmarshal(body, &content); err != nil { + if _, err := client.Do(req, &content); err != nil { return nil, err } return &content, nil @@ -125,8 +109,8 @@ func fetchContent(httpClient *http.Client, repo ghrepo.Interface, filePath, ref // populated only when the API returns it inline; larger files come back with empty content, and // it is up to the caller to fetch the raw bytes via fetchRawFile when the content is actually // needed. -func fetchFile(httpClient *http.Client, repo ghrepo.Interface, filePath, ref string) (*repoFile, error) { - content, err := fetchContent(httpClient, repo, filePath, ref) +func fetchFile(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, filePath, ref string) (*repoFile, error) { + content, err := fetchContent(ctx, client, repo, filePath, ref) if err != nil { return nil, err } @@ -173,29 +157,23 @@ func fetchFile(httpClient *http.Client, repo ghrepo.Interface, filePath, ref str // fetchRawFile retrieves the raw bytes of a file, used for files larger than the // 1 MB inline content limit of the Contents API. -func fetchRawFile(httpClient *http.Client, repo ghrepo.Interface, filePath, ref string) ([]byte, error) { +func fetchRawFile(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, filePath, ref string) ([]byte, error) { apiPath, err := contentsAPIPath(repo, filePath, ref) if err != nil { return nil, err } - req, err := http.NewRequest("GET", apiPath.String(), nil) + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil, githubrest.WithHeader("Accept", "application/vnd.github.raw")) if err != nil { return nil, err } - req.Header.Set("Accept", "application/vnd.github.raw") - resp, err := httpClient.Do(req) - if err != nil { + var buf bytes.Buffer + if _, err := client.Do(req, &buf); err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode > 299 { - return nil, api.HandleHTTPError(resp) - } - return io.ReadAll(resp.Body) + return buf.Bytes(), nil } // contentsAPIPath builds the absolute Contents API URL for a path and optional ref. diff --git a/pkg/cmd/repo/read-file/read_file.go b/pkg/cmd/repo/read-file/read_file.go index 5940236cf72..f8cac568220 100644 --- a/pkg/cmd/repo/read-file/read_file.go +++ b/pkg/cmd/repo/read-file/read_file.go @@ -1,9 +1,9 @@ package readfile import ( + "context" "errors" "fmt" - "net/http" "os" "path/filepath" "slices" @@ -11,6 +11,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -34,7 +35,7 @@ var fileFields = []string{ // ReadFileOptions holds the configuration for the read-file command. type ReadFileOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Exporter cmdutil.Exporter @@ -51,7 +52,7 @@ type ReadFileOptions struct { func NewCmdReadFile(f *cmdutil.Factory, runF func(*ReadFileOptions) error) *cobra.Command { opts := &ReadFileOptions{ IO: f.IOStreams, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, BaseRepo: f.BaseRepo, } @@ -109,7 +110,7 @@ func NewCmdReadFile(f *cmdutil.Factory, runF func(*ReadFileOptions) error) *cobr return runF(opts) } - return readFileRun(opts) + return readFileRun(cmd.Context(), opts) }, } @@ -125,18 +126,18 @@ func NewCmdReadFile(f *cmdutil.Factory, runF func(*ReadFileOptions) error) *cobr return cmd } -func readFileRun(opts *ReadFileOptions) error { - httpClient, err := opts.HttpClient() +func readFileRun(ctx context.Context, opts *ReadFileOptions) error { + repo, err := opts.BaseRepo() if err != nil { - return err + return fmt.Errorf("%w. Run this command from within a git repository, or use the `--repo` flag to specify one", err) } - repo, err := opts.BaseRepo() + client, err := opts.GitHubREST(repo.RepoHost()) if err != nil { - return fmt.Errorf("%w. Run this command from within a git repository, or use the `--repo` flag to specify one", err) + return err } - file, err := fetchFile(httpClient, repo, opts.Path, opts.Ref) + file, err := fetchFile(ctx, client, repo, opts.Path, opts.Ref) if err != nil { return err } @@ -147,7 +148,7 @@ func readFileRun(opts *ReadFileOptions) error { if opts.Exporter != nil { // Only pay for the file content when the caller actually selected the content field. if !contentAvailable && slices.Contains(opts.Exporter.Fields(), "content") { - if err := loadContent(httpClient, repo, file, opts.Ref); err != nil { + if err := loadContent(ctx, client, repo, file, opts.Ref); err != nil { return err } } @@ -155,7 +156,7 @@ func readFileRun(opts *ReadFileOptions) error { } if !contentAvailable { - if err := loadContent(httpClient, repo, file, opts.Ref); err != nil { + if err := loadContent(ctx, client, repo, file, opts.Ref); err != nil { return err } } @@ -213,12 +214,12 @@ func readFileRun(opts *ReadFileOptions) error { // loadContent fetches the raw file bytes when the Contents API did not return them inline. // The API only omits inline content for large files, which it marks with a "none" encoding; // everything else (including empty files) comes back base64-encoded, so there is nothing to fetch. -func loadContent(httpClient *http.Client, repo ghrepo.Interface, file *repoFile, ref string) error { +func loadContent(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, file *repoFile, ref string) error { if file.Encoding != "none" { return nil } - raw, err := fetchRawFile(httpClient, repo, file.Path, ref) + raw, err := fetchRawFile(ctx, client, repo, file.Path, ref) if err != nil { return err } diff --git a/pkg/cmd/repo/read-file/read_file_test.go b/pkg/cmd/repo/read-file/read_file_test.go index 59d3856e40b..6082edd6e89 100644 --- a/pkg/cmd/repo/read-file/read_file_test.go +++ b/pkg/cmd/repo/read-file/read_file_test.go @@ -2,6 +2,7 @@ package readfile import ( "bytes" + "context" "encoding/base64" "errors" "fmt" @@ -536,9 +537,7 @@ func Test_readFileRun(t *testing.T) { if opts.Path == "" { opts.Path = "README.md" } - opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + opts.GitHubREST = httpmock.RESTClientFunc(reg) if opts.BaseRepo == nil { opts.BaseRepo = func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil @@ -551,7 +550,7 @@ func Test_readFileRun(t *testing.T) { opts.Exporter = exporter } - err := readFileRun(&opts) + err := readFileRun(context.Background(), &opts) if tt.wantErrMsg != "" { require.Error(t, err) assert.Contains(t, err.Error(), tt.wantErrMsg) diff --git a/pkg/cmd/repo/rename/rename.go b/pkg/cmd/repo/rename/rename.go index 7a0c93da20f..433100901cc 100644 --- a/pkg/cmd/repo/rename/rename.go +++ b/pkg/cmd/repo/rename/rename.go @@ -3,15 +3,15 @@ package rename import ( "context" "fmt" - "net/http" "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" + "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -23,7 +23,7 @@ type iprompter interface { } type RenameOptions struct { - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) GitClient *git.Client IO *iostreams.IOStreams Prompter iprompter @@ -38,7 +38,7 @@ type RenameOptions struct { func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Command { opts := &RenameOptions{ IO: f.IOStreams, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, GitClient: f.GitClient, Remotes: f.Remotes, Config: f.Config, @@ -93,7 +93,7 @@ func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Co return runf(opts) } - return renameRun(opts) + return renameRun(cmd.Context(), opts) }, } @@ -105,12 +105,7 @@ func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Co return cmd } -func renameRun(opts *RenameOptions) error { - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - +func renameRun(ctx context.Context, opts *RenameOptions) error { newRepoName := opts.newRepoSelector currRepo, err := opts.BaseRepo() @@ -140,9 +135,12 @@ func renameRun(opts *RenameOptions) error { } } - apiClient := api.NewClientFromHTTP(httpClient) + restClient, err := opts.GitHubREST(currRepo.RepoHost()) + if err != nil { + return err + } - newRepo, err := api.RenameRepo(apiClient, currRepo, newRepoName) + newRepo, err := shared.RenameRepo(ctx, restClient, currRepo, newRepoName) if err != nil { return err } diff --git a/pkg/cmd/repo/rename/rename_test.go b/pkg/cmd/repo/rename/rename_test.go index e68530be001..0bb9c452d2a 100644 --- a/pkg/cmd/repo/rename/rename_test.go +++ b/pkg/cmd/repo/rename/rename_test.go @@ -2,10 +2,10 @@ package rename import ( "bytes" - "net/http" + "context" "testing" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" @@ -247,8 +247,8 @@ func TestRenameRun(t *testing.T) { return config.NewBlankConfig(), nil } - tt.opts.Remotes = func() (context.Remotes, error) { - return []*context.Remote{ + tt.opts.Remotes = func() (ghContext.Remotes, error) { + return []*ghContext.Remote{ { Remote: &git.Remote{Name: "origin"}, Repo: repo, @@ -266,9 +266,7 @@ func TestRenameRun(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, _ := iostreams.Test() ios.SetStdinTTY(tt.tty) @@ -279,7 +277,7 @@ func TestRenameRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { defer reg.Verify(t) - err := renameRun(&tt.opts) + err := renameRun(context.Background(), &tt.opts) if tt.wantErr { assert.EqualError(t, err, tt.errMsg) return diff --git a/pkg/cmd/repo/shared/http.go b/pkg/cmd/repo/shared/http.go new file mode 100644 index 00000000000..5006c922b52 --- /dev/null +++ b/pkg/cmd/repo/shared/http.go @@ -0,0 +1,217 @@ +package shared + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" + "github.com/cli/cli/v2/internal/safeurl" +) + +// repositoryV3 is the repository result from GitHub API v3. +type repositoryV3 struct { + NodeID string `json:"node_id"` + Name string + CreatedAt time.Time `json:"created_at"` + Owner struct { + Login string + } + Private bool + HTMLUrl string `json:"html_url"` + Parent *repositoryV3 +} + +// ForkRepo forks the repository on GitHub and returns the new repository. +func ForkRepo(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, org, newName string, defaultBranchOnly bool) (*api.Repository, error) { + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "forks") + if err != nil { + return nil, err + } + + params := map[string]interface{}{} + if org != "" { + params["organization"] = org + } + if newName != "" { + params["name"] = newName + } + if defaultBranchOnly { + params["default_branch_only"] = true + } + + body := &bytes.Buffer{} + enc := json.NewEncoder(body) + if err := enc.Encode(params); err != nil { + return nil, err + } + + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), body) + if err != nil { + return nil, err + } + + result := repositoryV3{} + if _, err := client.Do(req, &result); err != nil { + return nil, err + } + + newRepo := &api.Repository{ + ID: result.NodeID, + Name: result.Name, + CreatedAt: result.CreatedAt, + Owner: api.RepositoryOwner{ + Login: result.Owner.Login, + }, + ViewerPermission: "WRITE", + } + newRepo = api.InitRepoHostname(newRepo, repo.RepoHost()) + + // The GitHub API will happily return a HTTP 200 when attempting to fork own repo even though no forking + // actually took place. Ensure that we raise an error instead. + if ghrepo.IsSame(repo, newRepo) { + return newRepo, fmt.Errorf("%s cannot be forked. A single user account cannot own both a parent and fork.", ghrepo.FullName(repo)) + } + + return newRepo, nil +} + +// RenameRepo renames the repository on GitHub and returns the renamed repository. +func RenameRepo(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, newRepoName string) (*api.Repository, error) { + input := map[string]string{"name": newRepoName} + body := &bytes.Buffer{} + enc := json.NewEncoder(body) + if err := enc.Encode(input); err != nil { + return nil, err + } + + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName()) + if err != nil { + return nil, err + } + + req, err := client.NewRequest(ctx, http.MethodPatch, path.String(), body) + if err != nil { + return nil, err + } + + result := repositoryV3{} + if _, err := client.Do(req, &result); err != nil { + return nil, err + } + + renamed := &api.Repository{ + ID: result.NodeID, + Name: result.Name, + CreatedAt: result.CreatedAt, + Owner: api.RepositoryOwner{ + Login: result.Owner.Login, + }, + ViewerPermission: "WRITE", + } + return api.InitRepoHostname(renamed, repo.RepoHost()), nil +} + +// CreateRepoTransformToV4 issues a REST repository create/edit request and +// transforms the API v3 response into an api.Repository. +func CreateRepoTransformToV4(ctx context.Context, client *githubrest.Client, hostname string, method string, path safeurl.SafeURL, body io.Reader) (*api.Repository, error) { + req, err := client.NewRequest(ctx, method, path.String(), body) + if err != nil { + return nil, err + } + + var responsev3 repositoryV3 + if _, err := client.Do(req, &responsev3); err != nil { + return nil, err + } + + repo := &api.Repository{ + Name: responsev3.Name, + CreatedAt: responsev3.CreatedAt, + Owner: api.RepositoryOwner{ + Login: responsev3.Owner.Login, + }, + ID: responsev3.NodeID, + URL: responsev3.HTMLUrl, + IsPrivate: responsev3.Private, + } + return api.InitRepoHostname(repo, hostname), nil +} + +// RepoLicenses fetches available repository licenses. +// It uses API v3 because licenses are not supported by GraphQL. +func RepoLicenses(ctx context.Context, client *githubrest.Client, hostname string) ([]api.License, error) { + path, err := safeurl.JoinPath("licenses") + if err != nil { + return nil, err + } + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) + if err != nil { + return nil, err + } + var licenses []api.License + if _, err := client.Do(req, &licenses); err != nil { + return nil, err + } + return licenses, nil +} + +// RepoLicense fetches an available repository license. +// It uses API v3 because licenses are not supported by GraphQL. +func RepoLicense(ctx context.Context, client *githubrest.Client, hostname string, licenseName string) (*api.License, error) { + path, err := safeurl.JoinPath("licenses", licenseName) + if err != nil { + return nil, err + } + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) + if err != nil { + return nil, err + } + var license api.License + if _, err := client.Do(req, &license); err != nil { + return nil, err + } + return &license, nil +} + +// RepoGitIgnoreTemplates fetches available repository gitignore templates. +// It uses API v3 here because gitignore template isn't supported by GraphQL. +func RepoGitIgnoreTemplates(ctx context.Context, client *githubrest.Client, hostname string) ([]string, error) { + path, err := safeurl.JoinPath("gitignore", "templates") + if err != nil { + return nil, err + } + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) + if err != nil { + return nil, err + } + var gitIgnoreTemplates []string + if _, err := client.Do(req, &gitIgnoreTemplates); err != nil { + return nil, err + } + return gitIgnoreTemplates, nil +} + +// RepoGitIgnoreTemplate fetches an available repository gitignore template. +// It uses API v3 here because gitignore template isn't supported by GraphQL. +func RepoGitIgnoreTemplate(ctx context.Context, client *githubrest.Client, hostname string, gitIgnoreTemplateName string) (*api.GitIgnore, error) { + path, err := safeurl.JoinPath("gitignore", "templates", gitIgnoreTemplateName) + if err != nil { + return nil, err + } + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) + if err != nil { + return nil, err + } + var gitIgnoreTemplate api.GitIgnore + if _, err := client.Do(req, &gitIgnoreTemplate); err != nil { + return nil, err + } + return &gitIgnoreTemplate, nil +} diff --git a/pkg/cmd/repo/shared/http_test.go b/pkg/cmd/repo/shared/http_test.go new file mode 100644 index 00000000000..e3affb54d8f --- /dev/null +++ b/pkg/cmd/repo/shared/http_test.go @@ -0,0 +1,444 @@ +package shared + +import ( + "context" + "fmt" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestRESTClient(t *testing.T, reg *httpmock.Registry) *githubrest.Client { + t.Helper() + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + return client +} + +func TestForkRepoReturnsErrorWhenForkIsNotPossible(t *testing.T) { + // Given our API returns 202 with a Fork that is the same as + // the repo we provided + repoName := "test-repo" + ownerLogin := "test-owner" + stubbedForkResponse := repositoryV3{ + Name: repoName, + Owner: struct{ Login string }{ + Login: ownerLogin, + }, + } + + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("POST", fmt.Sprintf("repos/%s/%s/forks", ownerLogin, repoName)), + httpmock.StatusJSONResponse(202, stubbedForkResponse), + ) + + client := newTestRESTClient(t, reg) + + // When we fork the repo + _, err := ForkRepo(context.Background(), client, ghrepo.New(ownerLogin, repoName), ownerLogin, "", false) + + // Then it provides a useful error message + require.Equal(t, fmt.Errorf("%s/%s cannot be forked. A single user account cannot own both a parent and fork.", ownerLogin, repoName), err) +} + +func TestListLicenseTemplatesReturnsLicenses(t *testing.T) { + hostname := "api.github.com" + httpStubs := func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "licenses"), + httpmock.StringResponse(`[ + { + "key": "mit", + "name": "MIT License", + "spdx_id": "MIT", + "url": "https://api.github.com/licenses/mit", + "node_id": "MDc6TGljZW5zZW1pdA==" + }, + { + "key": "lgpl-3.0", + "name": "GNU Lesser General Public License v3.0", + "spdx_id": "LGPL-3.0", + "url": "https://api.github.com/licenses/lgpl-3.0", + "node_id": "MDc6TGljZW5zZW1pdA==" + }, + { + "key": "mpl-2.0", + "name": "Mozilla Public License 2.0", + "spdx_id": "MPL-2.0", + "url": "https://api.github.com/licenses/mpl-2.0", + "node_id": "MDc6TGljZW5zZW1pdA==" + }, + { + "key": "agpl-3.0", + "name": "GNU Affero General Public License v3.0", + "spdx_id": "AGPL-3.0", + "url": "https://api.github.com/licenses/agpl-3.0", + "node_id": "MDc6TGljZW5zZW1pdA==" + }, + { + "key": "unlicense", + "name": "The Unlicense", + "spdx_id": "Unlicense", + "url": "https://api.github.com/licenses/unlicense", + "node_id": "MDc6TGljZW5zZW1pdA==" + }, + { + "key": "apache-2.0", + "name": "Apache License 2.0", + "spdx_id": "Apache-2.0", + "url": "https://api.github.com/licenses/apache-2.0", + "node_id": "MDc6TGljZW5zZW1pdA==" + }, + { + "key": "gpl-3.0", + "name": "GNU General Public License v3.0", + "spdx_id": "GPL-3.0", + "url": "https://api.github.com/licenses/gpl-3.0", + "node_id": "MDc6TGljZW5zZW1pdA==" + } + ]`, + )) + } + wantLicenses := []api.License{ + { + Key: "mit", + Name: "MIT License", + SPDXID: "MIT", + URL: "https://api.github.com/licenses/mit", + NodeID: "MDc6TGljZW5zZW1pdA==", + HTMLURL: "", + Description: "", + Implementation: "", + Permissions: nil, + Conditions: nil, + Limitations: nil, + Body: "", + }, + { + Key: "lgpl-3.0", + Name: "GNU Lesser General Public License v3.0", + SPDXID: "LGPL-3.0", + URL: "https://api.github.com/licenses/lgpl-3.0", + NodeID: "MDc6TGljZW5zZW1pdA==", + HTMLURL: "", + Description: "", + Implementation: "", + Permissions: nil, + Conditions: nil, + Limitations: nil, + Body: "", + }, + { + Key: "mpl-2.0", + Name: "Mozilla Public License 2.0", + SPDXID: "MPL-2.0", + URL: "https://api.github.com/licenses/mpl-2.0", + NodeID: "MDc6TGljZW5zZW1pdA==", + HTMLURL: "", + Description: "", + Implementation: "", + Permissions: nil, + Conditions: nil, + Limitations: nil, + Body: "", + }, + { + Key: "agpl-3.0", + Name: "GNU Affero General Public License v3.0", + SPDXID: "AGPL-3.0", + URL: "https://api.github.com/licenses/agpl-3.0", + NodeID: "MDc6TGljZW5zZW1pdA==", + HTMLURL: "", + Description: "", + Implementation: "", + Permissions: nil, + Conditions: nil, + Limitations: nil, + Body: "", + }, + { + Key: "unlicense", + Name: "The Unlicense", + SPDXID: "Unlicense", + URL: "https://api.github.com/licenses/unlicense", + NodeID: "MDc6TGljZW5zZW1pdA==", + HTMLURL: "", + Description: "", + Implementation: "", + Permissions: nil, + Conditions: nil, + Limitations: nil, + Body: "", + }, + { + Key: "apache-2.0", + Name: "Apache License 2.0", + SPDXID: "Apache-2.0", + URL: "https://api.github.com/licenses/apache-2.0", + NodeID: "MDc6TGljZW5zZW1pdA==", + HTMLURL: "", + Description: "", + Implementation: "", + Permissions: nil, + Conditions: nil, + Limitations: nil, + Body: "", + }, + { + Key: "gpl-3.0", + Name: "GNU General Public License v3.0", + SPDXID: "GPL-3.0", + URL: "https://api.github.com/licenses/gpl-3.0", + NodeID: "MDc6TGljZW5zZW1pdA==", + HTMLURL: "", + Description: "", + Implementation: "", + Permissions: nil, + Conditions: nil, + Limitations: nil, + Body: "", + }, + } + + reg := &httpmock.Registry{} + httpStubs(reg) + defer reg.Verify(t) + + client := newTestRESTClient(t, reg) + + gotLicenses, err := RepoLicenses(context.Background(), client, hostname) + + assert.NoError(t, err, "Expected no error while fetching /licenses") + assert.Equal(t, wantLicenses, gotLicenses, "Licenses fetched is not as expected") +} + +func TestLicenseTemplateReturnsLicense(t *testing.T) { + licenseTemplateName := "mit" + hostname := "api.github.com" + httpStubs := func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("licenses/%v", licenseTemplateName)), + httpmock.StringResponse(`{ + "key": "mit", + "name": "MIT License", + "spdx_id": "MIT", + "url": "https://api.github.com/licenses/mit", + "node_id": "MDc6TGljZW5zZTEz", + "html_url": "http://choosealicense.com/licenses/mit/", + "description": "A short and simple permissive license with conditions only requiring preservation of copyright and license notices. Licensed works, modifications, and larger works may be distributed under different terms and without source code.", + "implementation": "Create a text file (typically named LICENSE or LICENSE.txt) in the root of your source code and copy the text of the license into the file. Replace [year] with the current year and [fullname] with the name (or names) of the copyright holders.", + "permissions": [ + "commercial-use", + "modifications", + "distribution", + "private-use" + ], + "conditions": [ + "include-copyright" + ], + "limitations": [ + "liability", + "warranty" + ], + "body": "MIT License\n\nCopyright (c) [year] [fullname]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "featured": true + }`, + )) + } + wantLicense := &api.License{ + Key: "mit", + Name: "MIT License", + SPDXID: "MIT", + URL: "https://api.github.com/licenses/mit", + NodeID: "MDc6TGljZW5zZTEz", + HTMLURL: "http://choosealicense.com/licenses/mit/", + Description: "A short and simple permissive license with conditions only requiring preservation of copyright and license notices. Licensed works, modifications, and larger works may be distributed under different terms and without source code.", + Implementation: "Create a text file (typically named LICENSE or LICENSE.txt) in the root of your source code and copy the text of the license into the file. Replace [year] with the current year and [fullname] with the name (or names) of the copyright holders.", + Permissions: []string{ + "commercial-use", + "modifications", + "distribution", + "private-use", + }, + Conditions: []string{ + "include-copyright", + }, + Limitations: []string{ + "liability", + "warranty", + }, + Body: "MIT License\n\nCopyright (c) [year] [fullname]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + Featured: true, + } + + reg := &httpmock.Registry{} + httpStubs(reg) + defer reg.Verify(t) + + client := newTestRESTClient(t, reg) + + gotLicenseTemplate, err := RepoLicense(context.Background(), client, hostname, licenseTemplateName) + + assert.NoError(t, err, fmt.Sprintf("Expected no error while fetching /licenses/%v", licenseTemplateName)) + assert.Equal(t, wantLicense, gotLicenseTemplate, fmt.Sprintf("License \"%v\" fetched is not as expected", licenseTemplateName)) +} + +func TestLicenseTemplateReturnsErrorWhenLicenseTemplateNotFound(t *testing.T) { + licenseTemplateName := "invalid-license" + hostname := "api.github.com" + httpStubs := func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("licenses/%v", licenseTemplateName)), + httpmock.StatusStringResponse(404, heredoc.Doc(` + { + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest/licenses/licenses#get-a-license", + "status": "404" + }`)), + ) + } + + reg := &httpmock.Registry{} + httpStubs(reg) + defer reg.Verify(t) + + client := newTestRESTClient(t, reg) + + _, err := RepoLicense(context.Background(), client, hostname, licenseTemplateName) + + assert.Error(t, err, fmt.Sprintf("Expected error while fetching /licenses/%v", licenseTemplateName)) +} + +func TestListGitIgnoreTemplatesReturnsGitIgnoreTemplates(t *testing.T) { + hostname := "api.github.com" + httpStubs := func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "gitignore/templates"), + httpmock.StringResponse(`[ + "AL", + "Actionscript", + "Ada", + "Agda", + "Android", + "AppEngine", + "AppceleratorTitanium", + "ArchLinuxPackages", + "Autotools", + "Ballerina", + "C", + "C++", + "CFWheels", + "CMake", + "CUDA", + "CakePHP", + "ChefCookbook", + "Clojure", + "CodeIgniter", + "CommonLisp", + "Composer", + "Concrete5", + "Coq", + "CraftCMS", + "D" + ]`, + )) + } + wantGitIgnoreTemplates := []string{ + "AL", + "Actionscript", + "Ada", + "Agda", + "Android", + "AppEngine", + "AppceleratorTitanium", + "ArchLinuxPackages", + "Autotools", + "Ballerina", + "C", + "C++", + "CFWheels", + "CMake", + "CUDA", + "CakePHP", + "ChefCookbook", + "Clojure", + "CodeIgniter", + "CommonLisp", + "Composer", + "Concrete5", + "Coq", + "CraftCMS", + "D", + } + + reg := &httpmock.Registry{} + httpStubs(reg) + defer reg.Verify(t) + + client := newTestRESTClient(t, reg) + + gotGitIgnoreTemplates, err := RepoGitIgnoreTemplates(context.Background(), client, hostname) + + assert.NoError(t, err, "Expected no error while fetching /gitignore/templates") + assert.Equal(t, wantGitIgnoreTemplates, gotGitIgnoreTemplates, "GitIgnore templates fetched is not as expected") +} + +func TestGitIgnoreTemplateReturnsGitIgnoreTemplate(t *testing.T) { + gitIgnoreTemplateName := "Go" + httpStubs := func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("gitignore/templates/%v", gitIgnoreTemplateName)), + httpmock.StringResponse(`{ + "name": "Go", + "source": "# If you prefer the allow list template instead of the deny list, see community template:\n# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore\n#\n# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Test binary, built with go test -c\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependency directories (remove the comment below to include it)\n# vendor/\n\n# Go workspace file\ngo.work\ngo.work.sum\n\n# env file\n.env\n" + }`, + )) + } + wantGitIgnoreTemplate := &api.GitIgnore{ + Name: "Go", + Source: "# If you prefer the allow list template instead of the deny list, see community template:\n# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore\n#\n# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Test binary, built with go test -c\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependency directories (remove the comment below to include it)\n# vendor/\n\n# Go workspace file\ngo.work\ngo.work.sum\n\n# env file\n.env\n", + } + + reg := &httpmock.Registry{} + httpStubs(reg) + defer reg.Verify(t) + + client := newTestRESTClient(t, reg) + + gotGitIgnoreTemplate, err := RepoGitIgnoreTemplate(context.Background(), client, "api.github.com", gitIgnoreTemplateName) + + assert.NoError(t, err, fmt.Sprintf("Expected no error while fetching /gitignore/templates/%v", gitIgnoreTemplateName)) + assert.Equal(t, wantGitIgnoreTemplate, gotGitIgnoreTemplate, fmt.Sprintf("GitIgnore template \"%v\" fetched is not as expected", gitIgnoreTemplateName)) +} + +func TestGitIgnoreTemplateReturnsErrorWhenGitIgnoreTemplateNotFound(t *testing.T) { + gitIgnoreTemplateName := "invalid-gitignore" + httpStubs := func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", fmt.Sprintf("gitignore/templates/%v", gitIgnoreTemplateName)), + httpmock.StatusStringResponse(404, heredoc.Doc(` + { + "message": "Not Found", + "documentation_url": "https://docs.github.com/v3/gitignore", + "status": "404" + }`)), + ) + } + + reg := &httpmock.Registry{} + httpStubs(reg) + defer reg.Verify(t) + + client := newTestRESTClient(t, reg) + + _, err := RepoGitIgnoreTemplate(context.Background(), client, "api.github.com", gitIgnoreTemplateName) + + assert.Error(t, err, fmt.Sprintf("Expected error while fetching /gitignore/templates/%v", gitIgnoreTemplateName)) +} diff --git a/pkg/cmd/repo/sync/http.go b/pkg/cmd/repo/sync/http.go index 7059d4137e6..01e4ab15465 100644 --- a/pkg/cmd/repo/sync/http.go +++ b/pkg/cmd/repo/sync/http.go @@ -2,13 +2,13 @@ package sync import ( "bytes" + "context" "encoding/json" "errors" "fmt" "net/http" "regexp" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" @@ -25,13 +25,17 @@ type commit struct { } `json:"object"` } -func latestCommit(client *api.Client, repo ghrepo.Interface, branch string) (commit, error) { +func latestCommit(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, branch string) (commit, error) { var response commit path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "git", "refs", fmt.Sprintf("heads/%s", branch)) if err != nil { return response, err } - err = client.REST(repo.RepoHost(), "GET", path.String(), nil, &response) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) + if err != nil { + return response, err + } + _, err = client.Do(req, &response) return response, err } @@ -40,7 +44,7 @@ type upstreamMergeErr struct{ error } var missingWorkflowScopeRE = regexp.MustCompile("refusing to allow.*without `workflow(s)?` (scope|permission)") var missingWorkflowScopeErr = errors.New("Upstream commits contain workflow changes, which require the `workflow` scope or permission to merge. To request it, run: gh auth refresh -s workflow") -func triggerUpstreamMerge(client *api.Client, repo ghrepo.Interface, branch string) (string, error) { +func triggerUpstreamMerge(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, branch string) (string, error) { var payload bytes.Buffer if err := json.NewEncoder(&payload).Encode(map[string]interface{}{ "branch": branch, @@ -57,8 +61,12 @@ func triggerUpstreamMerge(client *api.Client, repo ghrepo.Interface, branch stri if err != nil { return "", err } + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), &payload) + if err != nil { + return "", err + } var httpErr *githubrest.ErrorResponse - if err := client.REST(repo.RepoHost(), "POST", path.String(), &payload, &response); err != nil { + if _, err := client.Do(req, &response); err != nil { if errors.As(err, &httpErr) { switch httpErr.StatusCode { case http.StatusUnprocessableEntity, http.StatusConflict: @@ -73,7 +81,7 @@ func triggerUpstreamMerge(client *api.Client, repo ghrepo.Interface, branch stri return response.BaseBranch, nil } -func syncFork(client *api.Client, repo ghrepo.Interface, branch, SHA string, force bool) error { +func syncFork(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, branch, SHA string, force bool) error { path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "git", "refs", fmt.Sprintf("heads/%s", branch)) if err != nil { return err @@ -87,5 +95,10 @@ func syncFork(client *api.Client, repo ghrepo.Interface, branch, SHA string, for return err } requestBody := bytes.NewReader(requestByte) - return client.REST(repo.RepoHost(), "PATCH", path.String(), requestBody, nil) + req, err := client.NewRequest(ctx, http.MethodPatch, path.String(), requestBody) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err } diff --git a/pkg/cmd/repo/sync/sync.go b/pkg/cmd/repo/sync/sync.go index 72b95408560..52b72ae213d 100644 --- a/pkg/cmd/repo/sync/sync.go +++ b/pkg/cmd/repo/sync/sync.go @@ -1,6 +1,7 @@ package sync import ( + "context" "errors" "fmt" "net/http" @@ -8,7 +9,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/context" + ghContext "github.com/cli/cli/v2/context" gitpkg "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" @@ -24,9 +25,10 @@ const ( type SyncOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) + Remotes func() (ghContext.Remotes, error) Git gitClient DestArg string SrcArg string @@ -37,6 +39,7 @@ type SyncOptions struct { func NewCmdSync(f *cmdutil.Factory, runF func(*SyncOptions) error) *cobra.Command { opts := SyncOptions{ HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, IO: f.IOStreams, BaseRepo: f.BaseRepo, Remotes: f.Remotes, @@ -79,7 +82,7 @@ func NewCmdSync(f *cmdutil.Factory, runF func(*SyncOptions) error) *cobra.Comman if runF != nil { return runF(&opts) } - return syncRun(&opts) + return syncRun(c.Context(), &opts) }, } @@ -89,11 +92,11 @@ func NewCmdSync(f *cmdutil.Factory, runF func(*SyncOptions) error) *cobra.Comman return cmd } -func syncRun(opts *SyncOptions) error { +func syncRun(ctx context.Context, opts *SyncOptions) error { if opts.DestArg == "" { return syncLocalRepo(opts) } else { - return syncRemoteRepo(opts) + return syncRemoteRepo(ctx, opts) } } @@ -166,7 +169,7 @@ func syncLocalRepo(opts *SyncOptions) error { return nil } -func syncRemoteRepo(opts *SyncOptions) error { +func syncRemoteRepo(ctx context.Context, opts *SyncOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -191,8 +194,13 @@ func syncRemoteRepo(opts *SyncOptions) error { return fmt.Errorf("can't sync repositories from different hosts") } + restClient, err := opts.GitHubREST(destRepo.RepoHost()) + if err != nil { + return err + } + opts.IO.StartProgressIndicator() - baseBranchLabel, err := executeRemoteRepoSync(apiClient, destRepo, srcRepo, opts) + baseBranchLabel, err := executeRemoteRepoSync(ctx, apiClient, restClient, destRepo, srcRepo, opts) opts.IO.StopProgressIndicator() if err != nil { if errors.Is(err, divergingError) { @@ -279,7 +287,7 @@ func executeLocalRepoSync(srcRepo ghrepo.Interface, remote string, opts *SyncOpt // endpoint allows us to sync repositories that are not fast-forward merge compatible. Additionally, // the git references API endpoint gives more detailed error responses as to why the sync failed. // Unless the --force flag is specified we will not perform non-fast-forward merges. -func executeRemoteRepoSync(client *api.Client, destRepo, srcRepo ghrepo.Interface, opts *SyncOptions) (string, error) { +func executeRemoteRepoSync(ctx context.Context, client *api.Client, restClient *githubrest.Client, destRepo, srcRepo ghrepo.Interface, opts *SyncOptions) (string, error) { branchName := opts.Branch if branchName == "" { var err error @@ -290,7 +298,7 @@ func executeRemoteRepoSync(client *api.Client, destRepo, srcRepo ghrepo.Interfac } var apiErr upstreamMergeErr - if baseBranch, err := triggerUpstreamMerge(client, destRepo, branchName); err == nil { + if baseBranch, err := triggerUpstreamMerge(ctx, restClient, destRepo, branchName); err == nil { return baseBranch, nil } else if !errors.As(err, &apiErr) { return "", err @@ -307,7 +315,7 @@ func executeRemoteRepoSync(client *api.Client, destRepo, srcRepo ghrepo.Interfac } } - commit, err := latestCommit(client, srcRepo, branchName) + commit, err := latestCommit(ctx, restClient, srcRepo, branchName) if err != nil { return "", err } @@ -315,7 +323,7 @@ func executeRemoteRepoSync(client *api.Client, destRepo, srcRepo ghrepo.Interfac // Using string comparison is a brittle way to determine the error returned by the API // endpoint but unfortunately the API returns 422 for many reasons so we must // interpret the message provide better error messaging for our users. - err = syncFork(client, destRepo, branchName, commit.Object.SHA, opts.Force) + err = syncFork(ctx, restClient, destRepo, branchName, commit.Object.SHA, opts.Force) var httpErr *githubrest.ErrorResponse if err != nil { if errors.As(err, &httpErr) { diff --git a/pkg/cmd/repo/sync/sync_test.go b/pkg/cmd/repo/sync/sync_test.go index 74fa10d6ea0..b3f1f00f5ee 100644 --- a/pkg/cmd/repo/sync/sync_test.go +++ b/pkg/cmd/repo/sync/sync_test.go @@ -6,7 +6,9 @@ import ( "net/http" "testing" - "github.com/cli/cli/v2/context" + "context" + + ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" @@ -105,7 +107,7 @@ func Test_SyncRun(t *testing.T) { name string tty bool opts *SyncOptions - remotes []*context.Remote + remotes []*ghContext.Remote httpStubs func(*httpmock.Registry) gitStubs func(*mockGitClient) wantStdout string @@ -532,6 +534,7 @@ func Test_SyncRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, _ := iostreams.Test() ios.SetStdinTTY(tt.tty) @@ -544,9 +547,9 @@ func Test_SyncRun(t *testing.T) { return repo1, nil } - tt.opts.Remotes = func() (context.Remotes, error) { + tt.opts.Remotes = func() (ghContext.Remotes, error) { if tt.remotes == nil { - return []*context.Remote{ + return []*ghContext.Remote{ { Remote: &git.Remote{Name: "origin"}, Repo: repo1, @@ -563,7 +566,7 @@ func Test_SyncRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { tt.opts.Git = newMockGitClient(t, tt.gitStubs) defer reg.Verify(t) - err := syncRun(tt.opts) + err := syncRun(context.Background(), tt.opts) if tt.wantErr { assert.EqualError(t, err, tt.errMsg) return diff --git a/pkg/cmd/repo/view/http.go b/pkg/cmd/repo/view/http.go index 0e477b9e95d..a10ecf1cf3f 100644 --- a/pkg/cmd/repo/view/http.go +++ b/pkg/cmd/repo/view/http.go @@ -2,13 +2,13 @@ package view import ( "bytes" + "context" "encoding/base64" "errors" "fmt" "io" "net/http" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" @@ -24,8 +24,7 @@ type RepoReadme struct { BaseURL string } -func RepositoryReadme(client *http.Client, repo ghrepo.Interface, branch string) (*RepoReadme, error) { - apiClient := api.NewClientFromHTTP(client) +func RepositoryReadme(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, branch string) (*RepoReadme, error) { var response struct { Name string Content string @@ -37,8 +36,11 @@ func RepositoryReadme(client *http.Client, repo ghrepo.Interface, branch string) return nil, err } - err = apiClient.REST(repo.RepoHost(), "GET", readmePath.String(), nil, &response) + req, err := client.NewRequest(ctx, http.MethodGet, readmePath.String(), nil) if err != nil { + return nil, err + } + if _, err := client.Do(req, &response); err != nil { var httpError *githubrest.ErrorResponse if errors.As(err, &httpError) && httpError.StatusCode == 404 { return nil, NotFoundError diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go index b13276c80ae..8015006996a 100644 --- a/pkg/cmd/repo/view/view.go +++ b/pkg/cmd/repo/view/view.go @@ -1,6 +1,7 @@ package view import ( + "context" "errors" "fmt" "net/http" @@ -13,6 +14,7 @@ import ( "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -22,6 +24,7 @@ import ( type ViewOptions struct { HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) Browser browser.Browser @@ -37,6 +40,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman opts := ViewOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, BaseRepo: f.BaseRepo, Browser: f.Browser, Config: f.Config, @@ -62,7 +66,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman if runF != nil { return runF(&opts) } - return viewRun(&opts) + return viewRun(c.Context(), &opts) }, } @@ -77,7 +81,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman var defaultFields = []string{"name", "owner", "description"} -func viewRun(opts *ViewOptions) error { +func viewRun(ctx context.Context, opts *ViewOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err @@ -123,7 +127,11 @@ func viewRun(opts *ViewOptions) error { } if !opts.Web && opts.Exporter == nil { - readme, err = RepositoryReadme(httpClient, toView, opts.Branch) + restClient, err := opts.GitHubREST(toView.RepoHost()) + if err != nil { + return err + } + readme, err = RepositoryReadme(ctx, restClient, toView, opts.Branch) if err != nil && !errors.Is(err, NotFoundError) { return err } diff --git a/pkg/cmd/repo/view/view_test.go b/pkg/cmd/repo/view/view_test.go index f07f9de187a..865f3b7e86a 100644 --- a/pkg/cmd/repo/view/view_test.go +++ b/pkg/cmd/repo/view/view_test.go @@ -2,6 +2,7 @@ package view import ( "bytes" + "context" "fmt" "net/http" "testing" @@ -202,6 +203,7 @@ func Test_RepoView_Web(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -218,7 +220,7 @@ func Test_RepoView_Web(t *testing.T) { _, teardown := run.Stub() defer teardown(t) - if err := viewRun(opts); err != nil { + if err := viewRun(context.Background(), opts); err != nil { t.Errorf("viewRun() error = %v", err) } assert.Equal(t, "", stdout.String()) @@ -351,6 +353,7 @@ func Test_ViewRun(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) io, _, stdout, stderr := iostreams.Test() tt.opts.IO = io @@ -358,7 +361,7 @@ func Test_ViewRun(t *testing.T) { t.Run(tt.name, func(t *testing.T) { io.SetStdoutTTY(tt.stdoutTTY) - if err := viewRun(tt.opts); (err != nil) != tt.wantErr { + if err := viewRun(context.Background(), tt.opts); (err != nil) != tt.wantErr { t.Errorf("viewRun() error = %v, wantErr %v", err, tt.wantErr) } assert.Equal(t, tt.wantStderr, stderr.String()) @@ -416,6 +419,7 @@ func Test_ViewRun_NonMarkdownReadme(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -428,7 +432,7 @@ func Test_ViewRun_NonMarkdownReadme(t *testing.T) { t.Run(tt.name, func(t *testing.T) { io.SetStdoutTTY(tt.stdoutTTY) - if err := viewRun(opts); err != nil { + if err := viewRun(context.Background(), opts); err != nil { t.Errorf("viewRun() error = %v", err) } assert.Equal(t, tt.wantOut, stdout.String()) @@ -482,6 +486,7 @@ func Test_ViewRun_NoReadme(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -494,7 +499,7 @@ func Test_ViewRun_NoReadme(t *testing.T) { t.Run(tt.name, func(t *testing.T) { io.SetStdoutTTY(tt.stdoutTTY) - if err := viewRun(opts); err != nil { + if err := viewRun(context.Background(), opts); err != nil { t.Errorf("viewRun() error = %v", err) } assert.Equal(t, tt.wantOut, stdout.String()) @@ -552,6 +557,7 @@ func Test_ViewRun_NoDescription(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -564,7 +570,7 @@ func Test_ViewRun_NoDescription(t *testing.T) { t.Run(tt.name, func(t *testing.T) { io.SetStdoutTTY(tt.stdoutTTY) - if err := viewRun(opts); err != nil { + if err := viewRun(context.Background(), opts); err != nil { t.Errorf("viewRun() error = %v", err) } assert.Equal(t, tt.wantOut, stdout.String()) @@ -603,13 +609,14 @@ func Test_ViewRun_WithoutUsername(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, - IO: io, + GitHubREST: httpmock.RESTClientFunc(reg), + IO: io, Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, } - if err := viewRun(opts); err != nil { + if err := viewRun(context.Background(), opts); err != nil { t.Errorf("viewRun() error = %v", err) } @@ -689,6 +696,7 @@ func Test_ViewRun_HandlesSpecialCharacters(t *testing.T) { tt.opts.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) io, _, stdout, stderr := iostreams.Test() tt.opts.IO = io @@ -696,7 +704,7 @@ func Test_ViewRun_HandlesSpecialCharacters(t *testing.T) { t.Run(tt.name, func(t *testing.T) { io.SetStdoutTTY(tt.stdoutTTY) - if err := viewRun(tt.opts); (err != nil) != tt.wantErr { + if err := viewRun(context.Background(), tt.opts); (err != nil) != tt.wantErr { t.Errorf("viewRun() error = %v, wantErr %v", err, tt.wantErr) } assert.Equal(t, tt.wantStderr, stderr.String()) @@ -719,6 +727,7 @@ func Test_viewRun_json(t *testing.T) { HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), BaseRepo: func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }, @@ -730,7 +739,7 @@ func Test_viewRun_json(t *testing.T) { _, teardown := run.Stub() defer teardown(t) - err := viewRun(opts) + err := viewRun(context.Background(), opts) assert.NoError(t, err) assert.Equal(t, heredoc.Doc(` name: REPO diff --git a/pkg/cmd/skills/install/install.go b/pkg/cmd/skills/install/install.go index b56b4eeb6cc..3fb1b045fc2 100644 --- a/pkg/cmd/skills/install/install.go +++ b/pkg/cmd/skills/install/install.go @@ -1,6 +1,7 @@ package install import ( + "context" "encoding/base64" "errors" "fmt" @@ -12,12 +13,12 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" ghContext "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/skills/discovery" @@ -49,7 +50,7 @@ const ( type InstallOptions struct { IO *iostreams.IOStreams Telemetry ghtelemetry.EventRecorder - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Prompter prompter.Prompter GitClient *git.Client Remotes func() (ghContext.Remotes, error) @@ -80,7 +81,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru Prompter: f.Prompter, GitClient: f.GitClient, Remotes: f.Remotes, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -233,7 +234,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru if runF != nil { return runF(opts) } - return installRun(opts) + return installRun(cmd.Context(), opts) }, } @@ -252,7 +253,7 @@ func NewCmdInstall(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru return cmd } -func installRun(opts *InstallOptions) error { +func installRun(ctx context.Context, opts *InstallOptions) error { cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() @@ -269,14 +270,13 @@ func installRun(opts *InstallOptions) error { parseSkillFromOpts(opts) - httpClient, err := opts.HttpClient() - if err != nil { + hostname := opts.repo.RepoHost() + if err := source.ValidateSupportedHost(hostname); err != nil { return err } - apiClient := api.NewClientFromHTTP(httpClient) - hostname := opts.repo.RepoHost() - if err := source.ValidateSupportedHost(hostname); err != nil { + client, err := opts.GitHubREST(hostname) + if err != nil { return err } @@ -293,11 +293,11 @@ func installRun(opts *InstallOptions) error { visOwner := opts.repo.RepoOwner() visRepo := opts.repo.RepoName() go func() { - vis, err := discovery.FetchRepoVisibility(apiClient, hostname, visOwner, visRepo) + vis, err := discovery.FetchRepoVisibility(ctx, client, visOwner, visRepo) visCh <- visResult{vis: vis, err: err} }() - resolved, err := resolveVersion(opts, apiClient, hostname) + resolved, err := resolveVersion(ctx, opts, client) if err != nil { return err } @@ -306,14 +306,14 @@ func installRun(opts *InstallOptions) error { if discovery.IsSkillPath(opts.SkillName) { opts.IO.StartProgressIndicatorWithLabel("Looking up skill") - skill, err := discovery.DiscoverSkillByPath(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, opts.SkillName) + skill, err := discovery.DiscoverSkillByPath(ctx, client, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, opts.SkillName) opts.IO.StopProgressIndicator() if err != nil { return err } selectedSkills = []discovery.Skill{*skill} } else { - skills, err := discoverSkills(opts, apiClient, hostname, resolved) + skills, err := discoverSkills(ctx, opts, client, resolved) if err != nil { return err } @@ -323,7 +323,7 @@ func installRun(opts *InstallOptions) error { sourceHint: ghrepo.FullName(opts.repo), fetchDescriptions: func() { opts.IO.StartProgressIndicatorWithLabel("Fetching skill info") - discovery.FetchDescriptionsConcurrent(apiClient, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), skills, nil) + discovery.FetchDescriptionsConcurrent(ctx, client, opts.repo.RepoOwner(), opts.repo.RepoName(), skills, nil) opts.IO.StopProgressIndicator() }, }) @@ -343,7 +343,7 @@ func installRun(opts *InstallOptions) error { // to the original source repo. If detected, offer to install directly // from upstream instead. if len(selectedSkills) == 1 && selectedSkills[0].BlobSHA != "" { - upstreamRepo, detected, err := checkUpstreamProvenance(opts, apiClient, hostname, selectedSkills[0], resolved.SHA) + upstreamRepo, detected, err := checkUpstreamProvenance(ctx, opts, client, hostname, selectedSkills[0], resolved.SHA) if err != nil { return err } @@ -365,7 +365,7 @@ func installRun(opts *InstallOptions) error { opts.SkillSource = ghrepo.FullName(upstreamRepo) opts.version = "" opts.Pin = "" - return installRun(opts) + return installRun(ctx, opts) } if detected { upstreamSource = "republisher" @@ -398,7 +398,7 @@ func installRun(opts *InstallOptions) error { fmt.Fprintf(opts.IO.ErrOut, "\nInstalling to %s for %s...\n", friendlyDir(plan.dir), formatPlanHosts(plan.hosts)) } - result, err := installer.Install(&installer.Options{ + result, err := installer.Install(ctx, &installer.Options{ Host: hostname, Owner: opts.repo.RepoOwner(), Repo: opts.repo.RepoName(), @@ -407,7 +407,7 @@ func installRun(opts *InstallOptions) error { PinnedRef: opts.Pin, Skills: plan.skills, Dir: plan.dir, - Client: apiClient, + Client: client, OnProgress: installProgress(opts.IO, len(plan.skills)), }) @@ -620,9 +620,9 @@ func cutLast(s, sep string) (before, after string, found bool) { return s, "", false } -func resolveVersion(opts *InstallOptions, client *api.Client, hostname string) (*discovery.ResolvedRef, error) { +func resolveVersion(ctx context.Context, opts *InstallOptions, client *githubrest.Client) (*discovery.ResolvedRef, error) { opts.IO.StartProgressIndicatorWithLabel("Resolving version") - resolved, err := discovery.ResolveRef(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), opts.version) + resolved, err := discovery.ResolveRef(ctx, client, opts.repo.RepoOwner(), opts.repo.RepoName(), opts.version) opts.IO.StopProgressIndicator() if err != nil { return nil, fmt.Errorf("could not resolve version: %w", err) @@ -631,9 +631,9 @@ func resolveVersion(opts *InstallOptions, client *api.Client, hostname string) ( return resolved, nil } -func discoverSkills(opts *InstallOptions, client *api.Client, hostname string, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { +func discoverSkills(ctx context.Context, opts *InstallOptions, client *githubrest.Client, resolved *discovery.ResolvedRef) ([]discovery.Skill, error) { opts.IO.StartProgressIndicatorWithLabel("Discovering skills") - allSkills, err := discovery.DiscoverSkillsWithOptions(client, hostname, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, discovery.DiscoverOptions{}) + allSkills, err := discovery.DiscoverSkillsWithOptions(ctx, client, opts.repo.RepoOwner(), opts.repo.RepoName(), resolved.SHA, discovery.DiscoverOptions{}) opts.IO.StopProgressIndicator() if err != nil { var treeTooLarge *discovery.TreeTooLargeError @@ -1296,7 +1296,7 @@ func filterHiddenDirSkills(opts *InstallOptions, allSkills []discovery.Skill) ([ // re-publisher or redirect to the upstream. Non-interactive mode always // installs from the re-publisher. // Returns (repo to redirect to, whether upstream was detected, error). -func checkUpstreamProvenance(opts *InstallOptions, client *api.Client, hostname string, skill discovery.Skill, commitSHA string) (ghrepo.Interface, bool, error) { +func checkUpstreamProvenance(ctx context.Context, opts *InstallOptions, client *githubrest.Client, hostname string, skill discovery.Skill, commitSHA string) (ghrepo.Interface, bool, error) { u, err := safeurl.JoinPath("repos", opts.repo.RepoOwner(), opts.repo.RepoName(), "contents", skill.Path+"/SKILL.md") if err != nil { return nil, false, err @@ -1306,7 +1306,11 @@ func checkUpstreamProvenance(opts *InstallOptions, client *api.Client, hostname Content string `json:"content"` Encoding string `json:"encoding"` } - if err := client.REST(hostname, "GET", u.String(), nil, &fileResp); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, false, nil //nolint:nilerr // best-effort check; failing to fetch is not fatal + } + if _, err := client.Do(req, &fileResp); err != nil { return nil, false, nil //nolint:nilerr // best-effort check; failing to fetch is not fatal } if fileResp.Encoding != "base64" { diff --git a/pkg/cmd/skills/install/install_test.go b/pkg/cmd/skills/install/install_test.go index 9a78da48042..68e7b2b3b65 100644 --- a/pkg/cmd/skills/install/install_test.go +++ b/pkg/cmd/skills/install/install_test.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/base64" "fmt" - "net/http" "net/url" "os" "path/filepath" @@ -343,7 +342,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", Agent: "github-copilot", @@ -366,7 +365,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -391,7 +390,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -416,7 +415,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -440,7 +439,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -464,7 +463,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -487,7 +486,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, Prompter: &prompter.PrompterMock{}, SkillSource: "monalisa/skills-repo", @@ -513,7 +512,7 @@ func TestInstallRun(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -540,7 +539,7 @@ func TestInstallRun(t *testing.T) { require.NoError(t, os.MkdirAll(filepath.Join(targetDir, "git-commit"), 0o755)) return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -564,7 +563,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "nonexistent", @@ -594,7 +593,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "xlsx-pro", @@ -625,7 +624,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "bob/xlsx-pro", @@ -670,7 +669,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -697,7 +696,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -723,7 +722,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -749,7 +748,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -780,7 +779,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit@v1.2.0", @@ -808,7 +807,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "skills/git-commit", @@ -836,7 +835,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "terraform/code-generation/skills/terraform-style-guide", @@ -864,7 +863,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "packages/agent-skills/code-review", @@ -889,7 +888,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "https://github.com/monalisa/skills-repo", SkillName: "git-commit", @@ -922,7 +921,7 @@ func TestInstallRun(t *testing.T) { } return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", @@ -965,7 +964,7 @@ func TestInstallRun(t *testing.T) { } return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", @@ -990,7 +989,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -1058,7 +1057,7 @@ func TestInstallRun(t *testing.T) { } return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/octocat-skills", @@ -1086,7 +1085,7 @@ func TestInstallRun(t *testing.T) { } return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/octocat-skills", @@ -1116,7 +1115,7 @@ func TestInstallRun(t *testing.T) { } return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/octocat-skills", @@ -1142,7 +1141,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, Prompter: &prompter.PrompterMock{ MultiSelectFunc: func(prompt string, defaults []string, options []string) ([]int, error) { @@ -1177,7 +1176,7 @@ func TestInstallRun(t *testing.T) { } return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, Remotes: func() (context.Remotes, error) { @@ -1224,7 +1223,7 @@ func TestInstallRun(t *testing.T) { } return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/octocat-skills", @@ -1244,7 +1243,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{}, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "acme.ghes.com/monalisa/octocat-skills", @@ -1279,7 +1278,7 @@ func TestInstallRun(t *testing.T) { } return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/octocat-skills", @@ -1310,7 +1309,7 @@ func TestInstallRun(t *testing.T) { } return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillName: "git-commit", @@ -1338,7 +1337,7 @@ func TestInstallRun(t *testing.T) { } return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/octocat-skills", @@ -1378,7 +1377,7 @@ func TestInstallRun(t *testing.T) { } return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/octocat-skills", @@ -1408,7 +1407,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -1443,7 +1442,7 @@ func TestInstallRun(t *testing.T) { } return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/octocat-skills", @@ -1466,7 +1465,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -1490,7 +1489,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -1518,7 +1517,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -1544,7 +1543,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "hidden-skill", @@ -1573,7 +1572,7 @@ func TestInstallRun(t *testing.T) { t.Helper() return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", SkillName: "git-commit", @@ -1616,7 +1615,7 @@ func TestInstallRun(t *testing.T) { opts.Telemetry = &telemetry.NoOpService{} } - err := installRun(opts) + err := installRun(t.Context(), opts) if tt.wantErr != "" { require.Error(t, err) @@ -1659,9 +1658,9 @@ func TestInstallRun_AllInstallsRemoteSkills(t *testing.T) { ios, _, stdout, stderr := iostreams.Test() targetDir := t.TempDir() - err := installRun(&InstallOptions{ + err := installRun(t.Context(), &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/skills-repo", All: true, @@ -1723,9 +1722,9 @@ func TestInstallRun_DeduplicatesSharedProjectDirAcrossHosts(t *testing.T) { }, } - err := installRun(&InstallOptions{ + err := installRun(t.Context(), &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, GitClient: &git.Client{RepoDir: t.TempDir()}, SkillSource: "monalisa/octocat-skills", @@ -2266,7 +2265,7 @@ func TestRunLocalInstall(t *testing.T) { ios.SetStderrTTY(tt.isTTY) opts := tt.opts(ios, sourceDir, targetDir) - err := installRun(opts) + err := installRun(t.Context(), opts) if tt.wantErr != "" { require.Error(t, err) @@ -2577,9 +2576,9 @@ func TestInstallRun_TelemetryVisibility(t *testing.T) { recorder := &telemetry.EventRecorderSpy{} - err := installRun(&InstallOptions{ + err := installRun(t.Context(), &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, Prompter: &prompter.PrompterMock{}, SkillSource: "monalisa/octocat-skills", @@ -2675,9 +2674,9 @@ func TestInstallRun_TelemetryMultipleSkills(t *testing.T) { recorder := &telemetry.EventRecorderSpy{} - err := installRun(&InstallOptions{ + err := installRun(t.Context(), &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, Prompter: pm, SkillSource: "monalisa/octocat-skills", @@ -2746,7 +2745,7 @@ func TestInstallRun_UpstreamDetection(t *testing.T) { opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, Prompter: &prompter.PrompterMock{ SelectFunc: func(_ string, _ string, choices []string) (int, error) { @@ -2788,7 +2787,7 @@ func TestInstallRun_UpstreamDetection(t *testing.T) { opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, Prompter: &prompter.PrompterMock{ SelectFunc: func(_ string, _ string, choices []string) (int, error) { @@ -2825,7 +2824,7 @@ func TestInstallRun_UpstreamDetection(t *testing.T) { opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, Telemetry: &telemetry.NoOpService{}, SkillSource: "monalisa/skills-repo", @@ -2859,7 +2858,7 @@ func TestInstallRun_UpstreamDetection(t *testing.T) { opts: func(t *testing.T, ios *iostreams.IOStreams, reg *httpmock.Registry) *InstallOptions { return &InstallOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), GitClient: &git.Client{RepoDir: t.TempDir()}, Telemetry: &telemetry.NoOpService{}, SkillSource: "monalisa/skills-repo", @@ -2892,7 +2891,7 @@ func TestInstallRun_UpstreamDetection(t *testing.T) { ios.SetStderrTTY(tt.isTTY) opts := tt.opts(t, ios, reg) - err := installRun(opts) + err := installRun(t.Context(), opts) if tt.wantErr != "" { require.Error(t, err) diff --git a/pkg/cmd/skills/preview/preview.go b/pkg/cmd/skills/preview/preview.go index 500af05924e..07bb38dc60a 100644 --- a/pkg/cmd/skills/preview/preview.go +++ b/pkg/cmd/skills/preview/preview.go @@ -1,19 +1,19 @@ package preview import ( + "context" "fmt" "io" - "net/http" "path" "sort" "strings" "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" @@ -27,7 +27,7 @@ import ( type PreviewOptions struct { IO *iostreams.IOStreams Telemetry ghtelemetry.EventRecorder - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Prompter prompter.Prompter ExecutablePath string RenderFile func(string, string) string @@ -45,7 +45,7 @@ func NewCmdPreview(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru opts := &PreviewOptions{ IO: f.IOStreams, Telemetry: telemetry, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Prompter: f.Prompter, ExecutablePath: f.ExecutablePath, } @@ -116,7 +116,7 @@ func NewCmdPreview(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru if runF != nil { return runF(opts) } - return previewRun(opts) + return previewRun(c.Context(), opts) }, } @@ -125,7 +125,7 @@ func NewCmdPreview(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, ru return cmd } -func previewRun(opts *PreviewOptions) error { +func previewRun(ctx context.Context, opts *PreviewOptions) error { cs := opts.IO.ColorScheme() repo := opts.repo @@ -136,11 +136,10 @@ func previewRun(opts *PreviewOptions) error { return err } - httpClient, err := opts.HttpClient() + client, err := opts.GitHubREST(hostname) if err != nil { return err } - apiClient := api.NewClientFromHTTP(httpClient) // Kick off the visibility fetch in parallel with the preview work so // the extra API roundtrip doesn't add latency on the critical path. @@ -151,12 +150,12 @@ func previewRun(opts *PreviewOptions) error { } visCh := make(chan visResult, 1) go func() { - vis, err := discovery.FetchRepoVisibility(apiClient, hostname, owner, repoName) + vis, err := discovery.FetchRepoVisibility(ctx, client, owner, repoName) visCh <- visResult{vis: vis, err: err} }() opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Resolving %s/%s", owner, repoName)) - resolved, err := discovery.ResolveRef(apiClient, hostname, owner, repoName, opts.Version) + resolved, err := discovery.ResolveRef(ctx, client, owner, repoName, opts.Version) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("could not resolve version: %w", err) @@ -165,7 +164,7 @@ func previewRun(opts *PreviewOptions) error { var skill discovery.Skill if discovery.IsSkillPath(opts.SkillName) { opts.IO.StartProgressIndicatorWithLabel("Looking up skill") - found, err := discovery.DiscoverSkillByPathWithOptions(apiClient, hostname, owner, repoName, resolved.SHA, opts.SkillName, discovery.DiscoverSkillByPathOptions{SkipDescription: true}) + found, err := discovery.DiscoverSkillByPathWithOptions(ctx, client, owner, repoName, resolved.SHA, opts.SkillName, discovery.DiscoverSkillByPathOptions{SkipDescription: true}) opts.IO.StopProgressIndicator() if err != nil { return err @@ -173,7 +172,7 @@ func previewRun(opts *PreviewOptions) error { skill = *found } else { opts.IO.StartProgressIndicatorWithLabel("Discovering skills") - allSkills, err := discovery.DiscoverSkillsWithOptions(apiClient, hostname, owner, repoName, resolved.SHA, discovery.DiscoverOptions{}) + allSkills, err := discovery.DiscoverSkillsWithOptions(ctx, client, owner, repoName, resolved.SHA, discovery.DiscoverOptions{}) opts.IO.StopProgressIndicator() if err != nil { return err @@ -197,13 +196,13 @@ func previewRun(opts *PreviewOptions) error { opts.IO.StartProgressIndicatorWithLabel("Fetching skill content") var files []discovery.SkillFile if skill.TreeSHA != "" { - files, err = discovery.ListSkillFiles(apiClient, hostname, owner, repoName, skill.TreeSHA) + files, err = discovery.ListSkillFiles(ctx, client, owner, repoName, skill.TreeSHA) if err != nil { fmt.Fprintf(opts.IO.ErrOut, "warning: could not list skill files: %v\n", err) files = nil } } - content, err := discovery.FetchBlob(apiClient, hostname, owner, repoName, skill.BlobSHA) + content, err := discovery.FetchBlob(ctx, client, owner, repoName, skill.BlobSHA) opts.IO.StopProgressIndicator() if err != nil { return err @@ -223,10 +222,10 @@ func previewRun(opts *PreviewOptions) error { // Non-interactive or skill has only SKILL.md: dump through pager if !canPrompt || len(extraFiles) == 0 { - renderAllFiles(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) + renderAllFiles(ctx, opts, cs, skill, files, rendered, extraFiles, client, owner, repoName) } else { // Interactive with multiple files: show tree, then file picker - renderInteractive(opts, cs, skill, files, rendered, extraFiles, apiClient, hostname, owner, repoName) + renderInteractive(ctx, opts, cs, skill, files, rendered, extraFiles, client, owner, repoName) } dims := map[string]string{ @@ -264,9 +263,9 @@ func previewRun(opts *PreviewOptions) error { const visibilityWaitTimeout = 200 * time.Millisecond // renderAllFiles dumps the tree, SKILL.md, and all extra files through the pager. -func renderAllFiles(opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, +func renderAllFiles(ctx context.Context, opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, files []discovery.SkillFile, rendered string, extraFiles []discovery.SkillFile, - apiClient *api.Client, hostname, owner, repo string) { + client *githubrest.Client, owner, repo string) { opts.IO.DetectTerminalTheme() if err := opts.IO.StartPager(); err != nil { @@ -298,7 +297,7 @@ func renderAllFiles(opts *PreviewOptions, cs *iostreams.ColorScheme, skill disco fmt.Fprintf(out, "\n%s\n", cs.Muted("(skipped remaining files, size limit reached)")) break } - fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, f.SHA) + fileContent, fetchErr := discovery.FetchBlob(ctx, client, owner, repo, f.SHA) if fetchErr != nil { fmt.Fprintf(out, "\n%s\n\n%s\n", cs.Bold("── "+f.Path+" ──"), cs.Muted("(could not fetch file)")) continue @@ -315,9 +314,9 @@ func renderAllFiles(opts *PreviewOptions, cs *iostreams.ColorScheme, skill disco } // renderInteractive shows the file tree, then a picker to browse individual files. -func renderInteractive(opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, +func renderInteractive(ctx context.Context, opts *PreviewOptions, cs *iostreams.ColorScheme, skill discovery.Skill, files []discovery.SkillFile, renderedSkillMD string, extraFiles []discovery.SkillFile, - apiClient *api.Client, hostname, owner, repo string) { + client *githubrest.Client, owner, repo string) { // Show the file tree to stderr so it persists above the prompt fmt.Fprintf(opts.IO.ErrOut, "\n%s\n", cs.Bold(skill.DisplayName()+"/")) @@ -354,7 +353,7 @@ func renderInteractive(opts *PreviewOptions, cs *iostreams.ColorScheme, skill di selectedFile := extraFiles[idx-1] // Fetch on demand; don't hold blob data in memory - fileContent, fetchErr := discovery.FetchBlob(apiClient, hostname, owner, repo, selectedFile.SHA) + fileContent, fetchErr := discovery.FetchBlob(ctx, client, owner, repo, selectedFile.SHA) if fetchErr != nil { fmt.Fprintf(opts.IO.ErrOut, "%s could not fetch %s: %v\n", cs.Red("!"), selectedFile.Path, fetchErr) continue diff --git a/pkg/cmd/skills/preview/preview_test.go b/pkg/cmd/skills/preview/preview_test.go index 1ae93026bd7..b718019fc2f 100644 --- a/pkg/cmd/skills/preview/preview_test.go +++ b/pkg/cmd/skills/preview/preview_test.go @@ -4,7 +4,6 @@ import ( "encoding/base64" "fmt" "io" - "net/http" "strings" "testing" @@ -415,9 +414,7 @@ func TestPreviewRun(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) ios, _, stdout, _ := iostreams.Test() ios.SetStdoutTTY(tt.tty) @@ -427,7 +424,7 @@ func TestPreviewRun(t *testing.T) { tt.opts.Prompter = &prompter.PrompterMock{} tt.opts.Telemetry = &telemetry.NoOpService{} - err := previewRun(tt.opts) + err := previewRun(t.Context(), tt.opts) if tt.wantErr != "" { require.EqualError(t, err, tt.wantErr) @@ -444,9 +441,9 @@ func TestPreviewRun(t *testing.T) { func TestPreviewRun_UnsupportedHost(t *testing.T) { ios, _, _, _ := iostreams.Test() - err := previewRun(&PreviewOptions{ + err := previewRun(t.Context(), &PreviewOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, + GitHubREST: httpmock.RESTClientFunc(nil), repo: ghrepo.NewWithHost("github", "awesome-copilot", "acme.ghes.com"), Telemetry: &telemetry.NoOpService{}, }) @@ -507,13 +504,13 @@ func TestPreviewRun_Interactive(t *testing.T) { opts := &PreviewOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, repo: ghrepo.New("owner", "repo"), Telemetry: &telemetry.NoOpService{}, } - err := previewRun(opts) + err := previewRun(t.Context(), opts) require.NoError(t, err) assert.Contains(t, stdout.String(), "Selected Skill") } @@ -602,14 +599,14 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { opts := &PreviewOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", Telemetry: &telemetry.NoOpService{}, } - err := previewRun(opts) + err := previewRun(t.Context(), opts) require.NoError(t, err) out := stdout.String() @@ -682,7 +679,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { opts := &PreviewOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", @@ -693,7 +690,7 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { Telemetry: &telemetry.NoOpService{}, } - err := previewRun(opts) + err := previewRun(t.Context(), opts) require.NoError(t, err) out := stdout.String() @@ -712,14 +709,14 @@ func TestPreviewRun_ShowsFileTree(t *testing.T) { opts := &PreviewOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", Telemetry: &telemetry.NoOpService{}, } - err := previewRun(opts) + err := previewRun(t.Context(), opts) require.NoError(t, err) out := stdout.String() @@ -819,14 +816,14 @@ func TestPreviewRun_RenderLimits(t *testing.T) { opts := &PreviewOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", Telemetry: &telemetry.NoOpService{}, } - err := previewRun(opts) + err := previewRun(t.Context(), opts) require.NoError(t, err) out := stdout.String() @@ -857,14 +854,14 @@ func TestPreviewRun_RenderLimits(t *testing.T) { opts := &PreviewOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", Telemetry: &telemetry.NoOpService{}, } - err := previewRun(opts) + err := previewRun(t.Context(), opts) require.NoError(t, err) out := stdout.String() @@ -890,14 +887,14 @@ func TestPreviewRun_RenderLimits(t *testing.T) { opts := &PreviewOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("monalisa", "skills-repo"), SkillName: "my-skill", Telemetry: &telemetry.NoOpService{}, } - err := previewRun(opts) + err := previewRun(t.Context(), opts) require.NoError(t, err) out := stdout.String() @@ -965,14 +962,14 @@ func TestPreviewRun_InteractiveTelemetryCapturesSelectedSkillName(t *testing.T) opts := &PreviewOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: pm, Telemetry: recorder, repo: ghrepo.New("owner", "repo"), // SkillName intentionally left empty to simulate interactive selection } - err := previewRun(opts) + err := previewRun(t.Context(), opts) require.NoError(t, err) // Verify the telemetry event captured the interactively-selected skill name, not empty string @@ -1076,14 +1073,14 @@ func TestPreviewRun_TelemetryVisibility(t *testing.T) { opts := &PreviewOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{}, Telemetry: recorder, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", } - err := previewRun(opts) + err := previewRun(t.Context(), opts) require.NoError(t, err) require.Len(t, recorder.Events, 1) @@ -1249,14 +1246,14 @@ func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { opts := &PreviewOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("owner", "repo"), SkillName: "my-skill", Telemetry: &telemetry.NoOpService{}, } - err := previewRun(opts) + err := previewRun(t.Context(), opts) require.NoError(t, err) assert.Contains(t, stdout.String(), "My Skill") assert.Contains(t, stderr.String(), "skill(s) in hidden directories were excluded") @@ -1297,7 +1294,7 @@ func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { opts := &PreviewOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("owner", "repo"), SkillName: "hidden-skill", @@ -1305,7 +1302,7 @@ func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { Telemetry: &telemetry.NoOpService{}, } - err := previewRun(opts) + err := previewRun(t.Context(), opts) require.NoError(t, err) assert.Contains(t, stdout.String(), "My Skill") assert.Contains(t, stderr.String(), "Skills in hidden directories") @@ -1343,14 +1340,14 @@ func TestPreviewRun_HiddenDirSkillsExcluded(t *testing.T) { opts := &PreviewOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{}, repo: ghrepo.New("owner", "repo"), SkillName: "hidden-skill", Telemetry: &telemetry.NoOpService{}, } - err := previewRun(opts) + err := previewRun(t.Context(), opts) require.Error(t, err) assert.Contains(t, err.Error(), "no standard skills found") assert.Contains(t, err.Error(), "--allow-hidden-dirs") diff --git a/pkg/cmd/skills/publish/publish.go b/pkg/cmd/skills/publish/publish.go index 9c5c0f5e2f5..e6773aaa2d6 100644 --- a/pkg/cmd/skills/publish/publish.go +++ b/pkg/cmd/skills/publish/publish.go @@ -15,10 +15,10 @@ import ( "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/skills/discovery" @@ -33,7 +33,7 @@ import ( // PublishOptions holds all dependencies and user-provided flags for the publish command. type PublishOptions struct { IO *iostreams.IOStreams - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Config func() (gh.Config, error) Prompter prompter.Prompter GitClient *git.Client @@ -91,7 +91,7 @@ type repoSecurityResponse struct { func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra.Command { opts := &PublishOptions{ IO: f.IOStreams, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, Prompter: f.Prompter, GitClient: f.GitClient, @@ -154,7 +154,7 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. if runF != nil { return runF(opts) } - return publishRun(opts) + return publishRun(cmd.Context(), opts) }, } @@ -165,7 +165,7 @@ func NewCmdPublish(f *cmdutil.Factory, runF func(*PublishOptions) error) *cobra. return cmd } -func publishRun(opts *PublishOptions) error { +func publishRun(ctx context.Context, opts *PublishOptions) error { dir := opts.Dir if dir == "" { var err error @@ -185,7 +185,7 @@ func publishRun(opts *PublishOptions) error { // Client initialization is deferred until after local validation so that // simple errors (missing skills/, bad SKILL.md, etc.) are reported // without requiring an HTTP client. - var client *api.Client + var client *githubrest.Client host := opts.host var diagnostics []publishDiagnostic @@ -346,12 +346,6 @@ func publishRun(opts *PublishOptions) error { hasTopic := false var existingTags []tagEntry if owner != "" && repo != "" { - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - client = api.NewClientFromHTTP(httpClient) - if host == "" && repoInfo != nil { host = repoInfo.Repo.RepoHost() } @@ -366,22 +360,27 @@ func publishRun(opts *PublishOptions) error { return err } + client, err = opts.GitHubREST(host) + if err != nil { + return err + } + // Security and ruleset checks (advisory, always shown) var skillAbsDirs []string for _, skill := range skills { skillAbsDirs = append(skillAbsDirs, filepath.Join(dir, filepath.FromSlash(skill.Path))) } - securityDiags := checkSecuritySettings(client, host, owner, repo, skillAbsDirs) + securityDiags := checkSecuritySettings(ctx, client, owner, repo, skillAbsDirs) diagnostics = append(diagnostics, securityDiags...) - rulesetDiags := checkTagProtection(client, host, owner, repo) + rulesetDiags := checkTagProtection(ctx, client, owner, repo) diagnostics = append(diagnostics, rulesetDiags...) // Check topic (needed for publish flow, not a blocking error) - hasTopic = repoHasTopic(client, host, owner, repo) + hasTopic = repoHasTopic(ctx, client, owner, repo) // Fetch existing tags (needed for version suggestion) - existingTags = fetchTags(client, host, owner, repo) + existingTags = fetchTags(ctx, client, owner, repo) } else { diagnostics = append(diagnostics, detectMissingRepoDiagnostic(opts.GitClient, dir)...) } @@ -436,11 +435,11 @@ func publishRun(opts *PublishOptions) error { fmt.Fprintf(opts.IO.ErrOut, "\nPublishing to %s/%s...\n\n", owner, repo) - return runPublishRelease(opts, client, host, owner, repo, dir, repoInfo.RemoteName, hasTopic, existingTags) + return runPublishRelease(ctx, opts, client, owner, repo, dir, repoInfo.RemoteName, hasTopic, existingTags) } // repoHasTopic checks whether the repo has the agent-skills topic. -func repoHasTopic(client *api.Client, host, owner, repo string) bool { +func repoHasTopic(ctx context.Context, client *githubrest.Client, owner, repo string) bool { if client == nil { return false } @@ -449,7 +448,11 @@ func repoHasTopic(client *api.Client, host, owner, repo string) bool { return false } var resp repoTopicsResponse - if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return false + } + if _, err := client.Do(req, &resp); err != nil { return false } for _, t := range resp.Names { @@ -461,7 +464,7 @@ func repoHasTopic(client *api.Client, host, owner, repo string) bool { } // fetchTags returns the most recent tags from the repo. -func fetchTags(client *api.Client, host, owner, repo string) []tagEntry { +func fetchTags(ctx context.Context, client *githubrest.Client, owner, repo string) []tagEntry { if client == nil { return nil } @@ -471,14 +474,18 @@ func fetchTags(client *api.Client, host, owner, repo string) []tagEntry { } u.SetQuery("per_page", "10") var tags []tagEntry - if err := client.REST(host, "GET", u.String(), nil, &tags); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil + } + if _, err := client.Do(req, &tags); err != nil { return nil } return tags } // runPublishRelease handles the interactive publish flow: topic, tag, release, immutability. -func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, repo, dir, remoteName string, hasTopic bool, existingTags []tagEntry) error { +func runPublishRelease(ctx context.Context, opts *PublishOptions, client *githubrest.Client, owner, repo, dir, remoteName string, hasTopic bool, existingTags []tagEntry) error { cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() @@ -494,7 +501,7 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re } } if addTopic { - if err := addAgentSkillsTopic(client, host, owner, repo); err != nil { + if err := addAgentSkillsTopic(ctx, client, owner, repo); err != nil { fmt.Fprintf(opts.IO.ErrOut, "%s Could not add topic: %v\n", cs.WarningIcon(), err) fmt.Fprintf(opts.IO.ErrOut, " Add it manually: gh repo edit %s/%s --add-topic agent-skills\n", owner, repo) } else { @@ -560,7 +567,7 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re } // Offer to enable immutable releases - immutableEnabled := checkImmutableReleases(client, host, owner, repo) + immutableEnabled := checkImmutableReleases(ctx, client, owner, repo) if !immutableEnabled && canPrompt { enableImmutable, err := opts.Prompter.Confirm( "Enable immutable releases? (prevents tampering with published releases)", true) @@ -568,7 +575,7 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re return err } if enableImmutable { - if err := enableImmutableReleases(client, host, owner, repo); err != nil { + if err := enableImmutableReleases(ctx, client, owner, repo); err != nil { fmt.Fprintf(opts.IO.ErrOut, "%s Could not enable immutable releases: %v\n", cs.WarningIcon(), err) fmt.Fprintf(opts.IO.ErrOut, " Enable manually in Settings > General > Releases\n") } else { @@ -586,7 +593,7 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re currentBranch = b } } - defaultBranch := detectDefaultBranch(client, host, owner, repo) + defaultBranch := detectDefaultBranch(ctx, client, owner, repo) if currentBranch != "" && defaultBranch != "" && currentBranch != defaultBranch { fmt.Fprintf(opts.IO.ErrOut, "%s Publishing from branch %q (default is %q)\n", cs.WarningIcon(), currentBranch, defaultBranch) } @@ -624,7 +631,11 @@ func runPublishRelease(opts *PublishOptions, client *api.Client, host, owner, re var releaseResp struct { HTMLURL string `json:"html_url"` } - if err := client.REST(host, "POST", releasePath.String(), bytes.NewReader(releaseJSON), &releaseResp); err != nil { + req, err := client.NewRequest(ctx, http.MethodPost, releasePath.String(), bytes.NewReader(releaseJSON)) + if err != nil { + return fmt.Errorf("failed to create release: %w", err) + } + if _, err := client.Do(req, &releaseResp); err != nil { return fmt.Errorf("failed to create release: %w", err) } @@ -687,7 +698,7 @@ func ensurePushed(opts *PublishOptions, dir, remoteName string) error { } // detectDefaultBranch returns the default branch of the remote repo via the API. -func detectDefaultBranch(client *api.Client, host, owner, repo string) string { +func detectDefaultBranch(ctx context.Context, client *githubrest.Client, owner, repo string) string { if client == nil { return "" } @@ -698,14 +709,18 @@ func detectDefaultBranch(client *api.Client, host, owner, repo string) string { if err != nil { return "" } - if err := client.REST(host, "GET", apiPath.String(), nil, &result); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return "" + } + if _, err := client.Do(req, &result); err != nil { return "" } return result.DefaultBranch } // addAgentSkillsTopic adds the "agent-skills" topic to the repo, preserving existing topics. -func addAgentSkillsTopic(client *api.Client, host, owner, repo string) error { +func addAgentSkillsTopic(ctx context.Context, client *githubrest.Client, owner, repo string) error { apiPath, err := safeurl.JoinPath("repos", owner, repo, "topics") if err != nil { return err @@ -713,7 +728,11 @@ func addAgentSkillsTopic(client *api.Client, host, owner, repo string) error { // Fetch existing topics var resp repoTopicsResponse - if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { + getReq, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return fmt.Errorf("could not fetch existing topics: %w", err) + } + if _, err := client.Do(getReq, &resp); err != nil { return fmt.Errorf("could not fetch existing topics: %w", err) } @@ -729,11 +748,16 @@ func addAgentSkillsTopic(client *api.Client, host, owner, repo string) error { if err != nil { return fmt.Errorf("could not serialize topics: %w", err) } - return client.REST(host, "PUT", apiPath.String(), bytes.NewReader(topicsJSON), nil) + putReq, err := client.NewRequest(ctx, http.MethodPut, apiPath.String(), bytes.NewReader(topicsJSON)) + if err != nil { + return err + } + _, err = client.Do(putReq, nil) + return err } // checkImmutableReleases checks if immutable releases are enabled for the repo. -func checkImmutableReleases(client *api.Client, host, owner, repo string) bool { +func checkImmutableReleases(ctx context.Context, client *githubrest.Client, owner, repo string) bool { if client == nil { return false } @@ -744,24 +768,33 @@ func checkImmutableReleases(client *api.Client, host, owner, repo string) bool { var resp struct { Enabled bool `json:"enabled"` } - if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return false + } + if _, err := client.Do(req, &resp); err != nil { return false } return resp.Enabled } // enableImmutableReleases enables immutable releases for the repo. -func enableImmutableReleases(client *api.Client, host, owner, repo string) error { +func enableImmutableReleases(ctx context.Context, client *githubrest.Client, owner, repo string) error { apiPath, err := safeurl.JoinPath("repos", owner, repo, "immutable-releases") if err != nil { return err } body := bytes.NewReader([]byte(`{"enabled":true}`)) - return client.REST(host, "PATCH", apiPath.String(), body, nil) + req, err := client.NewRequest(ctx, http.MethodPatch, apiPath.String(), body) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err } // checkTagProtection checks whether tag protection rulesets are enabled. -func checkTagProtection(client *api.Client, host, owner, repo string) []publishDiagnostic { +func checkTagProtection(ctx context.Context, client *githubrest.Client, owner, repo string) []publishDiagnostic { if client == nil { return nil } @@ -770,7 +803,11 @@ func checkTagProtection(client *api.Client, host, owner, repo string) []publishD return nil } var rulesets []rulesetsResponse - if err := client.REST(host, "GET", apiPath.String(), nil, &rulesets); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return nil + } + if _, err := client.Do(req, &rulesets); err != nil { return nil } @@ -787,7 +824,7 @@ func checkTagProtection(client *api.Client, host, owner, repo string) []publishD } // checkSecuritySettings checks whether recommended security features are enabled. -func checkSecuritySettings(client *api.Client, host, owner, repo string, skillDirs []string) []publishDiagnostic { +func checkSecuritySettings(ctx context.Context, client *githubrest.Client, owner, repo string, skillDirs []string) []publishDiagnostic { if client == nil { return nil } @@ -796,7 +833,11 @@ func checkSecuritySettings(client *api.Client, host, owner, repo string, skillDi return nil } var resp repoSecurityResponse - if err := client.REST(host, "GET", apiPath.String(), nil, &resp); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return nil + } + if _, err := client.Do(req, &resp); err != nil { return nil } @@ -827,7 +868,13 @@ func checkSecuritySettings(client *api.Client, host, owner, repo string, skillDi if u, err := safeurl.JoinPath("repos", owner, repo, "code-scanning", "alerts"); err == nil { u.SetQuery("per_page", "1") u.SetQuery("state", "open") - if err := client.REST(host, "GET", u.String(), nil, new([]interface{})); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, u.String(), nil) + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "info", + message: "skills include code files but code scanning does not appear to be configured (Settings > Code security > Code scanning)", + }) + } else if _, err := client.Do(req, new([]interface{})); err != nil { diagnostics = append(diagnostics, publishDiagnostic{ severity: "info", message: "skills include code files but code scanning does not appear to be configured (Settings > Code security > Code scanning)", @@ -838,7 +885,13 @@ func checkSecuritySettings(client *api.Client, host, owner, repo string, skillDi if hasManifests { if dependabotPath, err := safeurl.JoinPath("repos", owner, repo, "vulnerability-alerts"); err == nil { - if err := client.REST(host, "GET", dependabotPath.String(), nil, nil); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, dependabotPath.String(), nil) + if err != nil { + diagnostics = append(diagnostics, publishDiagnostic{ + severity: "info", + message: "skills include dependency manifests but Dependabot alerts do not appear to be enabled (Settings > Code security > Dependabot)", + }) + } else if _, err := client.Do(req, nil); err != nil { diagnostics = append(diagnostics, publishDiagnostic{ severity: "info", message: "skills include dependency manifests but Dependabot alerts do not appear to be enabled (Settings > Code security > Dependabot)", diff --git a/pkg/cmd/skills/publish/publish_test.go b/pkg/cmd/skills/publish/publish_test.go index 757cc5126c2..4ac925389d7 100644 --- a/pkg/cmd/skills/publish/publish_test.go +++ b/pkg/cmd/skills/publish/publish_test.go @@ -3,7 +3,6 @@ package publish import ( "bytes" "fmt" - "net/http" "os" "path/filepath" "regexp" @@ -164,11 +163,11 @@ func TestPublishRun_UnsupportedHost(t *testing.T) { stubGitRemote(cs, map[string]string{"origin": "https://github.com/monalisa/skills-repo.git"}) ios, _, _, _ := iostreams.Test() - err := publishRun(&PublishOptions{ + err := publishRun(t.Context(), &PublishOptions{ IO: ios, Dir: dir, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return nil, nil }, + GitHubREST: httpmock.RESTClientFunc(nil), host: "acme.ghes.com", }) require.ErrorContains(t, err, "does not currently support GitHub Enterprise Server") @@ -296,7 +295,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, DryRun: true, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -335,7 +334,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, DryRun: true, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -371,7 +370,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, DryRun: true, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -430,7 +429,7 @@ func TestPublishRun(t *testing.T) { ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, }, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -583,7 +582,7 @@ func TestPublishRun(t *testing.T) { IO: ios, Dir: dir, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -638,7 +637,7 @@ func TestPublishRun(t *testing.T) { IO: ios, Dir: dir, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -704,7 +703,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, DryRun: true, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -771,7 +770,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, DryRun: true, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -940,7 +939,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, DryRun: true, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -1032,7 +1031,7 @@ func TestPublishRun(t *testing.T) { ConfirmFunc: func(msg string, def bool) (bool, error) { return true, nil }, }, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -1109,7 +1108,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, Tag: "v2.3.5", GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -1145,7 +1144,7 @@ func TestPublishRun(t *testing.T) { Dir: dir, Tag: "v1.0.0", // same as stubAllSecureRemote's existing tag GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -1179,7 +1178,7 @@ func TestPublishRun(t *testing.T) { IO: ios, Dir: dir, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -1293,7 +1292,7 @@ func TestPublishRun(t *testing.T) { }, }, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -1353,7 +1352,7 @@ func TestPublishRun(t *testing.T) { }, }, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -1412,7 +1411,7 @@ func TestPublishRun(t *testing.T) { }, }, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -1479,7 +1478,7 @@ func TestPublishRun(t *testing.T) { }, }, GitClient: newTestGitClient(), - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", } }, @@ -1510,7 +1509,7 @@ func TestPublishRun(t *testing.T) { } opts := tt.opts(ios, dir, reg) - err := publishRun(opts) + err := publishRun(t.Context(), opts) if tt.wantErr != "" { require.Error(t, err) @@ -1578,12 +1577,12 @@ func TestPublishRun_DirArgUsesTargetRemote(t *testing.T) { defer reg.Verify(t) stubAllSecureRemote(reg, "monalisa", "target-repo") - err := publishRun(&PublishOptions{ + err := publishRun(t.Context(), &PublishOptions{ IO: ios, Dir: targetRepo, DryRun: true, GitClient: &git.Client{GitPath: "some/path/git", RepoDir: cwdRepo}, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), host: "github.com", }) diff --git a/pkg/cmd/skills/search/search.go b/pkg/cmd/skills/search/search.go index 3ca2c9421f5..e7fddfacc46 100644 --- a/pkg/cmd/skills/search/search.go +++ b/pkg/cmd/skills/search/search.go @@ -1,6 +1,7 @@ package search import ( + "context" "errors" "fmt" "math" @@ -13,7 +14,6 @@ import ( "sync" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/gh/ghtelemetry" "github.com/cli/cli/v2/internal/githubrest" @@ -52,7 +52,7 @@ var SkillSearchFields = []string{ type SearchOptions struct { IO *iostreams.IOStreams Telemetry ghtelemetry.EventRecorder - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Config func() (gh.Config, error) Prompter prompter.Prompter ExecutablePath string // path to the current gh binary for install subprocess @@ -70,7 +70,7 @@ func NewCmdSearch(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, run opts := &SearchOptions{ IO: f.IOStreams, Telemetry: telemetry, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, Config: f.Config, Prompter: f.Prompter, ExecutablePath: f.ExecutablePath, @@ -129,7 +129,7 @@ func NewCmdSearch(f *cmdutil.Factory, telemetry ghtelemetry.CommandRecorder, run if runF != nil { return runF(opts) } - return searchRun(opts) + return searchRun(c.Context(), opts) }, } @@ -205,14 +205,7 @@ func (s skillResult) ExportData(fields []string) map[string]interface{} { return data } -func searchRun(opts *SearchOptions) error { - httpClient, err := opts.HttpClient() - if err != nil { - return err - } - - apiClient := api.NewClientFromHTTP(httpClient) - +func searchRun(ctx context.Context, opts *SearchOptions) error { cfg, err := opts.Config() if err != nil { return err @@ -222,9 +215,14 @@ func searchRun(opts *SearchOptions) error { return err } + client, err := opts.GitHubREST(host) + if err != nil { + return err + } + opts.IO.StartProgressIndicatorWithLabel("Searching for skills") - skills, err := searchByKeyword(apiClient, host, opts.Query, opts.Owner, opts.Page, opts.Limit) + skills, err := searchByKeyword(ctx, client, opts.Query, opts.Owner, opts.Page, opts.Limit) if err != nil { opts.IO.StopProgressIndicator() return err @@ -239,7 +237,7 @@ func searchRun(opts *SearchOptions) error { rankByRelevance(skills, opts.Query) skills = truncateForProcessing(skills, opts.Page, opts.Limit) - enrichSkills(apiClient, host, skills) + enrichSkills(ctx, client, skills) opts.IO.StopProgressIndicator() // Filter out noise and re-rank with enriched data (descriptions, stars). @@ -281,7 +279,7 @@ func noResultsMessage(opts *SearchOptions) string { // content match to catch skill names like "mcp-apps" when the user types // "mcp apps". When owner is non-empty, all queries are scoped to that // GitHub user/org via user: and the implicit owner search is skipped. -func searchByKeyword(client *api.Client, host, queryTerm, owner string, page, limit int) ([]skillResult, error) { +func searchByKeyword(ctx context.Context, client *githubrest.Client, queryTerm, owner string, page, limit int) ([]skillResult, error) { ownerScope := "" if owner != "" { ownerScope = " user:" + owner @@ -309,7 +307,7 @@ func searchByKeyword(client *api.Client, host, queryTerm, owner string, page, li wg.Add(1) go func() { defer wg.Done() - pathResult, pathErr = executeSearch(client, host, pathQ, 1, searchPageSize) + pathResult, pathErr = executeSearch(ctx, client, pathQ, 1, searchPageSize) }() // When no explicit --owner is set and the query looks like it could be a @@ -321,7 +319,7 @@ func searchByKeyword(client *api.Client, host, queryTerm, owner string, page, li wg.Add(1) go func() { defer wg.Done() - ownerResult, ownerErr = executeSearch(client, host, ownerQ, 1, searchPageSize) + ownerResult, ownerErr = executeSearch(ctx, client, ownerQ, 1, searchPageSize) }() } @@ -333,12 +331,12 @@ func searchByKeyword(client *api.Client, host, queryTerm, owner string, page, li wg.Add(1) go func() { defer wg.Done() - hyphenResult, hyphenErr = executeSearch(client, host, hyphenQ, 1, searchPageSize) + hyphenResult, hyphenErr = executeSearch(ctx, client, hyphenQ, 1, searchPageSize) }() } // Primary content search runs on the main goroutine. - primaryItems, _, primaryErr = fetchPrimaryPages(client, host, primaryQ, page, limit) + primaryItems, _, primaryErr = fetchPrimaryPages(ctx, client, primaryQ, page, limit) wg.Wait() if primaryErr != nil { @@ -388,7 +386,7 @@ func truncateForProcessing(skills []skillResult, page, limit int) []skillResult // enrichSkills fetches descriptions and star counts concurrently. // Each function collects results into a map; merges happen after both complete // to avoid concurrent writes to the shared skills slice. -func enrichSkills(client *api.Client, host string, skills []skillResult) { +func enrichSkills(ctx context.Context, client *githubrest.Client, skills []skillResult) { var descMap map[int]string var starsMap map[int]int @@ -396,11 +394,11 @@ func enrichSkills(client *api.Client, host string, skills []skillResult) { wg.Add(2) go func() { defer wg.Done() - descMap = fetchDescriptions(client, host, skills) + descMap = fetchDescriptions(ctx, client, skills) }() go func() { defer wg.Done() - starsMap = fetchRepoStars(client, host, skills) + starsMap = fetchRepoStars(ctx, client, skills) }() wg.Wait() @@ -734,7 +732,7 @@ func isRateLimitError(err error) bool { const rateLimitErrorMessage = "GitHub API rate limit exceeded. Please wait a minute and try again." // executeSearch performs a single GitHub Code Search API call. -func executeSearch(client *api.Client, host, query string, page, pageSize int) (*codeSearchResult, error) { +func executeSearch(ctx context.Context, client *githubrest.Client, query string, page, pageSize int) (*codeSearchResult, error) { apiPath, err := safeurl.JoinPath("search", "code") if err != nil { return nil, err @@ -743,16 +741,22 @@ func executeSearch(client *api.Client, host, query string, page, pageSize int) ( apiPath.SetQuery("per_page", strconv.Itoa(pageSize)) apiPath.SetQuery("page", strconv.Itoa(page)) var result codeSearchResult - err = client.REST(host, "GET", apiPath.String(), nil, &result) - if err != nil && isRateLimitError(err) { - return nil, fmt.Errorf("%s", rateLimitErrorMessage) + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return nil, err + } + if _, err := client.Do(req, &result); err != nil { + if isRateLimitError(err) { + return nil, fmt.Errorf("%s", rateLimitErrorMessage) + } + return &result, err } - return &result, err + return &result, nil } // fetchPrimaryPages fetches enough API pages from GitHub Code Search to // cover the requested display page, accounting for filtering losses. -func fetchPrimaryPages(client *api.Client, host, query string, displayPage, displayLimit int) ([]codeSearchItem, int, error) { +func fetchPrimaryPages(ctx context.Context, client *githubrest.Client, query string, displayPage, displayLimit int) ([]codeSearchItem, int, error) { // Over-fetch to account for deduplication + filtering losses. // The Code Search API is rate-limited at 10 req/min, so we keep // page fetching conservative. Two pages (200 results) provides a @@ -771,7 +775,7 @@ func fetchPrimaryPages(client *api.Client, host, query string, displayPage, disp var allItems []codeSearchItem var totalCount int for p := 1; p <= numPages; p++ { - result, err := executeSearch(client, host, query, p, searchPageSize) + result, err := executeSearch(ctx, client, query, p, searchPageSize) if err != nil { if p == 1 { return nil, 0, err @@ -840,7 +844,7 @@ func splitRepo(fullName string) (string, string) { // fetchDescriptions fetches SKILL.md frontmatter descriptions concurrently // for all search results. Each result may come from a different repo. -func fetchDescriptions(client *api.Client, host string, skills []skillResult) map[int]string { +func fetchDescriptions(ctx context.Context, client *githubrest.Client, skills []skillResult) map[int]string { const maxWorkers = 10 sem := make(chan struct{}, maxWorkers) var wg sync.WaitGroup @@ -858,7 +862,7 @@ func fetchDescriptions(client *api.Client, host string, skills []skillResult) ma sem <- struct{}{} defer func() { <-sem }() - content, err := discovery.FetchBlob(client, host, skills[idx].Owner, skills[idx].RepoName, skills[idx].BlobSHA) + content, err := discovery.FetchBlob(ctx, client, skills[idx].Owner, skills[idx].RepoName, skills[idx].BlobSHA) if err != nil { return } @@ -900,7 +904,7 @@ type repoInfo struct { // fetchRepoStars fetches stargazer counts for each unique repository in // the result set, using bounded concurrency. -func fetchRepoStars(client *api.Client, host string, skills []skillResult) map[int]int { +func fetchRepoStars(ctx context.Context, client *githubrest.Client, skills []skillResult) map[int]int { const maxWorkers = 10 sem := make(chan struct{}, maxWorkers) var wg sync.WaitGroup @@ -926,7 +930,11 @@ func fetchRepoStars(client *api.Client, host string, skills []skillResult) map[i return } var info repoInfo - if err := client.REST(host, "GET", apiPath.String(), nil, &info); err != nil { + req, err := client.NewRequest(ctx, http.MethodGet, apiPath.String(), nil) + if err != nil { + return + } + if _, err := client.Do(req, &info); err != nil { return } mu.Lock() diff --git a/pkg/cmd/skills/search/search_test.go b/pkg/cmd/skills/search/search_test.go index cf66ba4acb4..804db7b1f38 100644 --- a/pkg/cmd/skills/search/search_test.go +++ b/pkg/cmd/skills/search/search_test.go @@ -2,7 +2,6 @@ package search import ( "io" - "net/http" "strings" "testing" @@ -25,12 +24,12 @@ func TestSearchRun_UnsupportedHost(t *testing.T) { cfg.AuthenticationFunc = func() gh.AuthConfig { return authCfg } - err := searchRun(&SearchOptions{ + err := searchRun(t.Context(), &SearchOptions{ IO: ios, Query: "terraform", Page: 1, Limit: defaultLimit, - HttpClient: func() (*http.Client, error) { return &http.Client{}, nil }, + GitHubREST: httpmock.RESTClientFunc(nil), Config: func() (gh.Config, error) { return cfg, nil }, }) require.ErrorContains(t, err, "does not currently support GitHub Enterprise Server") @@ -363,9 +362,7 @@ func TestSearchRun(t *testing.T) { if tt.httpStubs != nil { tt.httpStubs(reg) } - tt.opts.HttpClient = func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - } + tt.opts.GitHubREST = httpmock.RESTClientFunc(reg) tt.opts.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } @@ -377,7 +374,7 @@ func TestSearchRun(t *testing.T) { tt.opts.Telemetry = &telemetry.NoOpService{} defer reg.Verify(t) - err := searchRun(tt.opts) + err := searchRun(t.Context(), tt.opts) if tt.wantErr != "" { require.Error(t, err) @@ -620,9 +617,9 @@ func TestSearchRun_TelemetryRecordsInstallFromResults(t *testing.T) { recorder := &telemetry.EventRecorderSpy{} - err := searchRun(&SearchOptions{ + err := searchRun(t.Context(), &SearchOptions{ IO: ios, - HttpClient: func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, Prompter: pm, Telemetry: recorder, diff --git a/pkg/cmd/skills/update/update.go b/pkg/cmd/skills/update/update.go index a6ba369192e..123aa23ee4e 100644 --- a/pkg/cmd/skills/update/update.go +++ b/pkg/cmd/skills/update/update.go @@ -1,17 +1,17 @@ package update import ( + "context" "fmt" - "net/http" "os" "path/filepath" "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/skills/discovery" "github.com/cli/cli/v2/internal/skills/frontmatter" @@ -26,7 +26,7 @@ import ( // UpdateOptions holds all dependencies and user-provided flags for the update command. type UpdateOptions struct { IO *iostreams.IOStreams - HttpClient func() (*http.Client, error) + GitHubREST func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) Config func() (gh.Config, error) Prompter prompter.Prompter GitClient *git.Client @@ -69,7 +69,7 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Co Prompter: f.Prompter, Config: f.Config, GitClient: f.GitClient, - HttpClient: f.HttpClient, + GitHubREST: f.GitHubREST, } cmd := &cobra.Command{ @@ -127,7 +127,7 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Co if runF != nil { return runF(opts) } - return updateRun(opts) + return updateRun(cmd.Context(), opts) }, } @@ -140,15 +140,24 @@ func NewCmdUpdate(f *cmdutil.Factory, runF func(*UpdateOptions) error) *cobra.Co return cmd } -func updateRun(opts *UpdateOptions) error { +func updateRun(ctx context.Context, opts *UpdateOptions) error { cs := opts.IO.ColorScheme() canPrompt := opts.IO.CanPrompt() - httpClient, err := opts.HttpClient() - if err != nil { - return err + // Installed skills can span multiple hosts, so a githubrest.Client (which is + // bound to one host) is built and cached per host rather than once. + clients := map[string]*githubrest.Client{} + clientFor := func(host string) (*githubrest.Client, error) { + if c, ok := clients[host]; ok { + return c, nil + } + c, err := opts.GitHubREST(host) + if err != nil { + return nil, err + } + clients[host] = c + return c, nil } - apiClient := api.NewClientFromHTTP(httpClient) gitRoot := installer.ResolveGitRoot(opts.GitClient) homeDir := installer.ResolveHomeDir() @@ -271,7 +280,11 @@ func updateRun(opts *UpdateOptions) error { // Resolve ref and discover skills once per repo if _, ok := repoRefs[key]; !ok { - resolved, resolveErr := discovery.ResolveRef(apiClient, s.repoHost, s.owner, s.repo, "") + client, clientErr := clientFor(s.repoHost) + if clientErr != nil { + return clientErr + } + resolved, resolveErr := discovery.ResolveRef(ctx, client, s.owner, s.repo, "") if resolveErr != nil { repoErrors[key] = true opts.IO.StopProgressIndicator() @@ -281,7 +294,7 @@ func updateRun(opts *UpdateOptions) error { } repoRefs[key] = resolved - skills, discoverErr := discovery.DiscoverSkills(apiClient, s.repoHost, s.owner, s.repo, resolved.SHA) + skills, discoverErr := discovery.DiscoverSkills(ctx, client, s.owner, s.repo, resolved.SHA) if discoverErr != nil { repoErrors[key] = true opts.IO.StopProgressIndicator() @@ -385,7 +398,11 @@ func updateRun(opts *UpdateOptions) error { var failed bool for _, u := range updates { - if err := updateSkillInPlace(opts, u, apiClient, gitRoot, homeDir); err != nil { + client, clientErr := clientFor(u.local.repoHost) + if clientErr != nil { + return clientErr + } + if err := updateSkillInPlace(ctx, opts, u, client, gitRoot, homeDir); err != nil { fmt.Fprintf(opts.IO.ErrOut, "%s Failed to update %s: %v\n", cs.FailureIcon(), u.local.name, err) failed = true continue @@ -415,7 +432,7 @@ func updateRun(opts *UpdateOptions) error { // - A failure at any point (install, read, rename) leaves the existing // skill completely untouched: existing files are first moved aside into // a backup directory and restored if any subsequent step fails. -func updateSkillInPlace(opts *UpdateOptions, u pendingUpdate, apiClient *api.Client, gitRoot, homeDir string) error { +func updateSkillInPlace(ctx context.Context, opts *UpdateOptions, u pendingUpdate, client *githubrest.Client, gitRoot, homeDir string) error { if u.local.dir == "" { return fmt.Errorf("cannot update %s: no install location recorded", u.local.name) } @@ -443,9 +460,9 @@ func updateSkillInPlace(opts *UpdateOptions, u pendingUpdate, apiClient *api.Cli Dir: staging, GitRoot: gitRoot, HomeDir: homeDir, - Client: apiClient, + Client: client, } - if _, err := installer.Install(installOpts); err != nil { + if _, err := installer.Install(ctx, installOpts); err != nil { return err } diff --git a/pkg/cmd/skills/update/update_test.go b/pkg/cmd/skills/update/update_test.go index cb6caac6306..6721dc011ef 100644 --- a/pkg/cmd/skills/update/update_test.go +++ b/pkg/cmd/skills/update/update_test.go @@ -2,7 +2,6 @@ package update import ( "fmt" - "net/http" "os" "path/filepath" "testing" @@ -354,12 +353,10 @@ func TestUpdateRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + GitClient: &git.Client{RepoDir: dir}, } }, wantStderr: "All skills are up to date.", @@ -370,13 +367,11 @@ func TestUpdateRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, } }, wantStderr: "No installed skills found.", @@ -400,14 +395,12 @@ func TestUpdateRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - Skills: []string{"nonexistent"}, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Skills: []string{"nonexistent"}, } }, wantErr: "none of the specified skills are installed", @@ -433,14 +426,12 @@ func TestUpdateRun(t *testing.T) { ios.SetStdoutTTY(true) ios.SetStderrTTY(true) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - Prompter: &prompter.PrompterMock{}, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, } }, wantStderr: "pinned", @@ -463,13 +454,11 @@ func TestUpdateRun(t *testing.T) { ios.SetStdoutTTY(false) ios.SetStdinTTY(false) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, } }, wantStderr: "no GitHub metadata", @@ -493,11 +482,9 @@ func TestUpdateRun(t *testing.T) { ios.SetStdinTTY(true) ios.SetStderrTTY(true) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{ InputFunc: func(prompt string, defaultValue string) (string, error) { return "", fmt.Errorf("unexpected prompt") @@ -543,13 +530,11 @@ func TestUpdateRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, } }, wantStderr: "All skills are up to date.", @@ -588,15 +573,13 @@ func TestUpdateRun(t *testing.T) { ios.SetStdoutTTY(true) ios.SetStderrTTY(true) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - Prompter: &prompter.PrompterMock{}, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - DryRun: true, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, } }, wantStderr: "1 update(s) available:", @@ -636,13 +619,11 @@ func TestUpdateRun(t *testing.T) { ios.SetStdoutTTY(false) ios.SetStdinTTY(false) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, } }, wantErr: "updates available; re-run with --all to apply, or run interactively to confirm", @@ -688,15 +669,13 @@ func TestUpdateRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - All: true, - Force: true, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Force: true, } }, verify: func(t *testing.T, dir string) { @@ -751,15 +730,13 @@ func TestUpdateRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - All: true, - Force: true, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Force: true, } }, verify: func(t *testing.T, dir string) { @@ -821,14 +798,12 @@ func TestUpdateRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - All: true, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, } }, verify: func(t *testing.T, dir string) { @@ -883,11 +858,9 @@ func TestUpdateRun(t *testing.T) { ios.SetStdinTTY(true) ios.SetStderrTTY(true) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{ ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { return true, nil @@ -938,11 +911,9 @@ func TestUpdateRun(t *testing.T) { ios.SetStdinTTY(true) ios.SetStderrTTY(true) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{ ConfirmFunc: func(msg string, defaultVal bool) (bool, error) { return false, nil @@ -974,11 +945,9 @@ func TestUpdateRun(t *testing.T) { ios.SetStdinTTY(true) ios.SetStderrTTY(true) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{ InputFunc: func(prompt string, defaultValue string) (string, error) { return "", nil @@ -1029,11 +998,9 @@ func TestUpdateRun(t *testing.T) { ios.SetStdinTTY(true) ios.SetStderrTTY(true) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), Prompter: &prompter.PrompterMock{ InputFunc: func(prompt string, defaultValue string) (string, error) { return "monalisa/octocat-skills", nil @@ -1097,15 +1064,13 @@ func TestUpdateRun(t *testing.T) { opts: func(ios *iostreams.IOStreams, dir string, reg *httpmock.Registry) *UpdateOptions { ios.SetStdoutTTY(false) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - All: true, - Unpin: true, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + All: true, + Unpin: true, } }, verify: func(t *testing.T, dir string) { @@ -1138,15 +1103,13 @@ func TestUpdateRun(t *testing.T) { ios.SetStdoutTTY(true) ios.SetStderrTTY(true) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - Prompter: &prompter.PrompterMock{}, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - Unpin: false, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + Unpin: false, } }, wantStderr: "pinned", @@ -1184,16 +1147,14 @@ func TestUpdateRun(t *testing.T) { ios.SetStdoutTTY(true) ios.SetStderrTTY(true) return &UpdateOptions{ - IO: ios, - Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil - }, - Prompter: &prompter.PrompterMock{}, - GitClient: &git.Client{RepoDir: dir}, - Dir: dir, - DryRun: true, - Unpin: true, + IO: ios, + Config: func() (gh.Config, error) { return config.NewBlankConfig(), nil }, + GitHubREST: httpmock.RESTClientFunc(reg), + Prompter: &prompter.PrompterMock{}, + GitClient: &git.Client{RepoDir: dir}, + Dir: dir, + DryRun: true, + Unpin: true, } }, verify: func(t *testing.T, dir string) { @@ -1223,7 +1184,7 @@ func TestUpdateRun(t *testing.T) { } opts := tt.opts(ios, dir, reg) - err := updateRun(opts) + err := updateRun(t.Context(), opts) if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) From 9e00aca769a65e8f0984d666ac9adf9fe1cab59e Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 16:44:39 +0200 Subject: [PATCH 18/21] Delete the REST surface from the api package Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- api/client.go | 156 +---------------- api/client_test.go | 282 ------------------------------ api/queries_org.go | 1 + pkg/cmd/auth/shared/login_flow.go | 3 +- pkg/cmd/extension/extension.go | 5 +- pkg/cmd/extension/http.go | 95 ++++------ pkg/cmd/extension/http_test.go | 18 +- pkg/cmd/extension/manager.go | 76 +++++--- pkg/cmd/extension/manager_test.go | 16 +- pkg/cmd/factory/default.go | 1 + 10 files changed, 114 insertions(+), 539 deletions(-) diff --git a/api/client.go b/api/client.go index 7b6365d07cb..8b9ab8a86f9 100644 --- a/api/client.go +++ b/api/client.go @@ -2,17 +2,10 @@ package api import ( "context" - "encoding/json" "errors" - "fmt" - "io" "net/http" - "regexp" - "strings" - "github.com/cli/cli/v2/internal/githubrest" ghAPI "github.com/cli/go-gh/v2/pkg/api" - ghauth "github.com/cli/go-gh/v2/pkg/auth" ) const ( @@ -25,8 +18,6 @@ const ( userAgent = "User-Agent" ) -var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) - func NewClientFromHTTP(httpClient *http.Client) *Client { client := &Client{http: httpClient} return client @@ -92,85 +83,15 @@ func (c Client) QueryWithContext(ctx context.Context, hostname, name string, que return handleResponse(gqlClient.QueryWithContext(ctx, name, query, variables)) } -// REST performs a REST request and parses the response. -func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error { - opts := clientOptions(hostname, c.http.Transport) - restClient, err := ghAPI.NewRESTClient(opts) - if err != nil { - return err - } - return handleResponse(restClient.Do(method, p, body, data)) -} - -func (c Client) RESTWithNext(hostname string, method string, p string, body io.Reader, data interface{}) (string, error) { - opts := clientOptions(hostname, c.http.Transport) - restClient, err := ghAPI.NewRESTClient(opts) - if err != nil { - return "", err - } - - resp, err := restClient.Request(method, p, body) - if err != nil { - return "", handleResponse(err) - } - defer resp.Body.Close() - - if resp.StatusCode == http.StatusNoContent { - return "", nil - } - - b, err := io.ReadAll(resp.Body) - if err != nil { - return "", err - } - - err = json.Unmarshal(b, &data) - if err != nil { - return "", err - } - - var next string - for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) { - if len(m) > 2 && m[2] == "next" { - next = m[1] - } - } - - return next, nil -} - -// HandleHTTPError parses a http.Response into a *githubrest.ErrorResponse. -// -// The caller is responsible to close the response body stream. -func HandleHTTPError(resp *http.Response) error { - return githubrest.NewErrorResponse(resp) -} - -// handleResponse takes a ghAPI.HTTPError or ghAPI.GraphQLError and converts it into a -// *githubrest.ErrorResponse or GraphQLError respectively. +// handleResponse converts a ghAPI.GraphQLError into this package's GraphQLError. // -// Returning githubrest's error type from the api package is interop scaffolding: -// it means migrated and unmigrated call sites agree on one error type while the -// REST migration is in flight, so no call site has to handle both. +// It no longer has a REST leg: every REST request goes through +// internal/githubrest, which builds its own errors. func handleResponse(err error) error { if err == nil { return nil } - var restErr *ghAPI.HTTPError - if errors.As(err, &restErr) { - errResp := &githubrest.ErrorResponse{ - Headers: restErr.Headers, - Message: restErr.Message, - RequestURL: restErr.RequestURL, - StatusCode: restErr.StatusCode, - } - for _, item := range restErr.Errors { - errResp.Errors = append(errResp.Errors, githubrest.ErrorItem(item)) - } - return errResp - } - var gqlErr *ghAPI.GraphQLError if errors.As(err, &gqlErr) { return GraphQLError{ @@ -181,77 +102,6 @@ func handleResponse(err error) error { return err } -// ScopesSuggestion is an error messaging utility that prints the suggestion to request additional OAuth -// scopes in case a server response indicates that there are missing scopes. -func ScopesSuggestion(resp *http.Response) string { - return generateScopesSuggestion(resp.StatusCode, - resp.Header.Get("X-Accepted-Oauth-Scopes"), - resp.Header.Get("X-Oauth-Scopes"), - resp.Request.URL.Hostname()) -} - -// EndpointNeedsScopes adds additional OAuth scopes to an HTTP response as if they were returned from the -// server endpoint. This improves HTTP 4xx error messaging for endpoints that don't explicitly list the -// OAuth scopes they need. -func EndpointNeedsScopes(resp *http.Response, s string) { - if resp.StatusCode >= 400 && resp.StatusCode < 500 { - oldScopes := resp.Header.Get("X-Accepted-Oauth-Scopes") - resp.Header.Set("X-Accepted-Oauth-Scopes", fmt.Sprintf("%s, %s", oldScopes, s)) - } -} - -func generateScopesSuggestion(statusCode int, endpointNeedsScopes, tokenHasScopes, hostname string) string { - if statusCode < 400 || statusCode > 499 || statusCode == 422 { - return "" - } - - if tokenHasScopes == "" { - return "" - } - - gotScopes := map[string]struct{}{} - for _, s := range strings.Split(tokenHasScopes, ",") { - s = strings.TrimSpace(s) - gotScopes[s] = struct{}{} - - // Certain scopes may be grouped under a single "top-level" scope. The following branch - // statements include these grouped/implied scopes when the top-level scope is encountered. - // See https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps. - if s == "repo" { - gotScopes["repo:status"] = struct{}{} - gotScopes["repo_deployment"] = struct{}{} - gotScopes["public_repo"] = struct{}{} - gotScopes["repo:invite"] = struct{}{} - gotScopes["security_events"] = struct{}{} - } else if s == "user" { - gotScopes["read:user"] = struct{}{} - gotScopes["user:email"] = struct{}{} - gotScopes["user:follow"] = struct{}{} - } else if s == "codespace" { - gotScopes["codespace:secrets"] = struct{}{} - } else if strings.HasPrefix(s, "admin:") { - gotScopes["read:"+strings.TrimPrefix(s, "admin:")] = struct{}{} - gotScopes["write:"+strings.TrimPrefix(s, "admin:")] = struct{}{} - } else if strings.HasPrefix(s, "write:") { - gotScopes["read:"+strings.TrimPrefix(s, "write:")] = struct{}{} - } - } - - for _, s := range strings.Split(endpointNeedsScopes, ",") { - s = strings.TrimSpace(s) - if _, gotScope := gotScopes[s]; s == "" || gotScope { - continue - } - return fmt.Sprintf( - "This API operation needs the %[1]q scope. To request it, run: gh auth refresh -h %[2]s -s %[1]s", - s, - ghauth.NormalizeHostname(hostname), - ) - } - - return "" -} - func clientOptions(hostname string, transport http.RoundTripper) ghAPI.ClientOptions { // AuthToken, and Headers are being handled by transport, // so let go-gh know that it does not need to resolve them. diff --git a/api/client_test.go b/api/client_test.go index c57708354fb..86287a8f42e 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -1,18 +1,12 @@ package api import ( - "bytes" - "errors" "io" "net/http" - "net/http/httptest" "testing" - "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" - "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) func newTestClient(reg *httpmock.Registry) *Client { @@ -76,279 +70,3 @@ func TestGraphQLError(t *testing.T) { t.Fatalf("got %q", err.Error()) } } - -func TestRESTGetDelete(t *testing.T) { - http := &httpmock.Registry{} - client := newTestClient(http) - - http.Register( - httpmock.REST("DELETE", "applications/CLIENTID/grant"), - httpmock.StatusStringResponse(204, "{}"), - ) - - r := bytes.NewReader([]byte(`{}`)) - err := client.REST("github.com", "DELETE", "applications/CLIENTID/grant", r, nil) - assert.NoError(t, err) -} - -func TestRESTWithFullURL(t *testing.T) { - http := &httpmock.Registry{} - client := newTestClient(http) - - http.Register( - httpmock.REST("GET", "api/v3/user/repos"), - httpmock.StatusStringResponse(200, "{}")) - http.Register( - httpmock.REST("GET", "user/repos"), - httpmock.StatusStringResponse(200, "{}")) - - err := client.REST("example.com", "GET", "user/repos", nil, nil) - assert.NoError(t, err) - err = client.REST("example.com", "GET", "https://another.net/user/repos", nil, nil) - assert.NoError(t, err) - - assert.Equal(t, "example.com", http.Requests[0].URL.Hostname()) - assert.Equal(t, "another.net", http.Requests[1].URL.Hostname()) -} - -func TestRESTError(t *testing.T) { - fakehttp := &httpmock.Registry{} - client := newTestClient(fakehttp) - - fakehttp.Register(httpmock.MatchAny, func(req *http.Request) (*http.Response, error) { - return &http.Response{ - Request: req, - StatusCode: 422, - Body: io.NopCloser(bytes.NewBufferString(`{"message": "OH NO"}`)), - Header: map[string][]string{ - "Content-Type": {"application/json; charset=utf-8"}, - }, - }, nil - }) - - var httpErr *githubrest.ErrorResponse - err := client.REST("github.com", "DELETE", "repos/branch", nil, nil) - if err == nil || !errors.As(err, &httpErr) { - t.Fatalf("got %v", err) - } - - if httpErr.StatusCode != 422 { - t.Errorf("expected status code 422, got %d", httpErr.StatusCode) - } - if httpErr.Error() != "HTTP 422: OH NO (https://api.github.com/repos/branch)" { - t.Errorf("got %q", httpErr.Error()) - } -} - -func TestRESTWithNextError(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - client := newTestClient(reg) - - reg.Register(httpmock.MatchAny, func(req *http.Request) (*http.Response, error) { - return &http.Response{ - Request: req, - StatusCode: http.StatusNotFound, - Body: io.NopCloser(bytes.NewBufferString(`{"message": "Not Found"}`)), - Header: http.Header{ - "Content-Type": {"application/json"}, - "X-Accepted-Oauth-Scopes": {"repo"}, - "X-Oauth-Scopes": {"read:user"}, - }, - }, nil - }) - - _, err := client.RESTWithNext("github.com", http.MethodGet, "repos/owner/repo/items", nil, nil) - - var httpErr *githubrest.ErrorResponse - require.ErrorAs(t, err, &httpErr) - assert.Equal(t, http.StatusNotFound, httpErr.StatusCode) - assert.Contains(t, err.Error(), "HTTP 404") - assert.Equal(t, `This API operation needs the "repo" scope. To request it, run: gh auth refresh -h github.com -s repo`, httpErr.ScopesSuggestion()) -} - -func TestRESTAndRESTWithNextErrorTypeParity(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - client := newTestClient(reg) - - responder := func(req *http.Request) (*http.Response, error) { - return &http.Response{ - Request: req, - StatusCode: http.StatusNotFound, - Body: io.NopCloser(bytes.NewBufferString(`{"message": "Not Found"}`)), - Header: http.Header{"Content-Type": {"application/json"}}, - }, nil - } - reg.Register(httpmock.MatchAny, responder) - reg.Register(httpmock.MatchAny, responder) - - restErr := client.REST("github.com", http.MethodGet, "repos/owner/repo/items", nil, nil) - _, restWithNextErr := client.RESTWithNext("github.com", http.MethodGet, "repos/owner/repo/items", nil, nil) - - require.Error(t, restErr) - require.Error(t, restWithNextErr) - assert.IsType(t, restErr, restWithNextErr) -} - -func TestRESTWithNext(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - client := newTestClient(reg) - - reg.Register(httpmock.MatchAny, func(req *http.Request) (*http.Response, error) { - return &http.Response{ - Request: req, - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewBufferString(`{"name": "item"}`)), - Header: http.Header{ - "Content-Type": {"application/json"}, - "Link": {`; rel="next", ; rel="last"`}, - }, - }, nil - }) - - response := struct { - Name string `json:"name"` - }{} - next, err := client.RESTWithNext("github.com", http.MethodGet, "repos/owner/repo/items", nil, &response) - - require.NoError(t, err) - assert.Equal(t, "item", response.Name) - assert.Equal(t, "https://api.github.com/repos/owner/repo/items?page=2", next) -} - -func TestRESTWithNextNoContent(t *testing.T) { - reg := &httpmock.Registry{} - defer reg.Verify(t) - client := newTestClient(reg) - - reg.Register( - httpmock.REST(http.MethodDelete, "repos/owner/repo/items/1"), - httpmock.StatusStringResponse(http.StatusNoContent, "not JSON"), - ) - - next, err := client.RESTWithNext("github.com", http.MethodDelete, "repos/owner/repo/items/1", nil, nil) - - require.NoError(t, err) - assert.Empty(t, next) -} - -func TestHandleHTTPError_GraphQL502(t *testing.T) { - req, err := http.NewRequest("GET", "https://api.github.com/user", nil) - if err != nil { - t.Fatal(err) - } - resp := &http.Response{ - Request: req, - StatusCode: 502, - Body: io.NopCloser(bytes.NewBufferString(`{ "data": null, "errors": [{ "message": "Something went wrong" }] }`)), - Header: map[string][]string{"Content-Type": {"application/json"}}, - } - err = HandleHTTPError(resp) - if err == nil || err.Error() != "HTTP 502: Something went wrong (https://api.github.com/user)" { - t.Errorf("got error: %v", err) - } -} - -func TestErrorResponse_ScopesSuggestion(t *testing.T) { - makeResponse := func(s int, u, haveScopes, needScopes string) *http.Response { - req, err := http.NewRequest("GET", u, nil) - if err != nil { - t.Fatal(err) - } - return &http.Response{ - Request: req, - StatusCode: s, - Body: io.NopCloser(bytes.NewBufferString(`{}`)), - Header: map[string][]string{ - "Content-Type": {"application/json"}, - "X-Oauth-Scopes": {haveScopes}, - "X-Accepted-Oauth-Scopes": {needScopes}, - }, - } - } - - tests := []struct { - name string - resp *http.Response - want string - }{ - { - name: "has necessary scopes", - resp: makeResponse(404, "https://api.github.com/gists", "repo, gist, read:org", "gist"), - want: ``, - }, - { - name: "normalizes scopes", - resp: makeResponse(404, "https://api.github.com/orgs/ORG/discussions", "admin:org, write:discussion", "read:org, read:discussion"), - want: ``, - }, - { - name: "no scopes on endpoint", - resp: makeResponse(404, "https://api.github.com/user", "repo", ""), - want: ``, - }, - { - name: "missing a scope", - resp: makeResponse(404, "https://api.github.com/gists", "repo, read:org", "gist, delete_repo"), - want: `This API operation needs the "gist" scope. To request it, run: gh auth refresh -h github.com -s gist`, - }, - { - name: "server error", - resp: makeResponse(500, "https://api.github.com/gists", "repo", "gist"), - want: ``, - }, - { - name: "no scopes on token", - resp: makeResponse(404, "https://api.github.com/gists", "", "gist, delete_repo"), - want: ``, - }, - { - name: "http code is 422", - resp: makeResponse(422, "https://api.github.com/gists", "", "gist"), - want: "", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - httpError := HandleHTTPError(tt.resp) - if got := httpError.(*githubrest.ErrorResponse).ScopesSuggestion(); got != tt.want { - t.Errorf("HTTPError.ScopesSuggestion() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestHTTPHeaders(t *testing.T) { - var gotReq *http.Request - ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotReq = r - w.WriteHeader(http.StatusNoContent) - })) - defer ts.Close() - - ios, _, _, stderr := iostreams.Test() - httpClient, err := NewHTTPClient(HTTPClientOptions{ - AppVersion: "v1.2.3", - Config: tinyConfig{ts.URL[7:] + ":oauth_token": "MYTOKEN"}, - Log: ios.ErrOut, - }) - assert.NoError(t, err) - client := NewClientFromHTTP(httpClient) - - err = client.REST(ts.URL, "GET", ts.URL+"/user/repos", nil, nil) - assert.NoError(t, err) - - wantHeader := map[string]string{ - "Accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview", - "Authorization": "token MYTOKEN", - "Content-Type": "application/json; charset=utf-8", - "User-Agent": "GitHub CLI v1.2.3", - "X-GitHub-Api-Version": "2022-11-28", - } - for name, value := range wantHeader { - assert.Equal(t, value, gotReq.Header.Get(name), name) - } - assert.Equal(t, "", stderr.String()) -} diff --git a/api/queries_org.go b/api/queries_org.go index f2e93342eab..ca79f193f47 100644 --- a/api/queries_org.go +++ b/api/queries_org.go @@ -2,6 +2,7 @@ package api import ( "fmt" + "github.com/cli/cli/v2/internal/ghrepo" "github.com/shurcooL/githubv4" ) diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 50f2319aaa6..46672c81a40 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -11,7 +11,6 @@ import ( "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/authflow" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/ghinstance" @@ -285,7 +284,7 @@ func GetCurrentLogin(httpClient httpClient, hostname, authToken string) (string, } defer res.Body.Close() if res.StatusCode > 299 { - return "", api.HandleHTTPError(res) + return "", githubrest.NewErrorResponse(res) } decoder := json.NewDecoder(res.Body) err = decoder.Decode(&result) diff --git a/pkg/cmd/extension/extension.go b/pkg/cmd/extension/extension.go index d8b14f99b17..7f919402718 100644 --- a/pkg/cmd/extension/extension.go +++ b/pkg/cmd/extension/extension.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "net/http" "os" "path/filepath" "strings" @@ -29,7 +28,7 @@ type Extension struct { path string kind ExtensionKind gitClient gitClient - httpClient *http.Client + gitHubREST restClientFactory mu sync.RWMutex @@ -130,7 +129,7 @@ func (e *Extension) LatestVersion() string { if err != nil { return "" } - restClient, err := restClientFromHTTP(e.httpClient, repo.RepoHost()) + restClient, err := e.gitHubREST(repo.RepoHost()) if err != nil { return "" } diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go index abdfa522d93..67697f29e5b 100644 --- a/pkg/cmd/extension/http.go +++ b/pkg/cmd/extension/http.go @@ -1,6 +1,7 @@ package extension import ( + "bytes" "context" "encoding/json" "errors" @@ -8,41 +9,32 @@ import ( "net/http" "os" - "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" ) -// restClientFromHTTP builds a githubrest.Client that reaches host through -// httpClient. -// -// The extension manager and each Extension hold an already-authenticated -// *http.Client whose transport carries the token, so the client is constructed -// WithoutToken and leans on that transport for auth, exactly as the api.Client -// it replaced did. -func restClientFromHTTP(httpClient *http.Client, host string) (*githubrest.Client, error) { - return githubrest.NewClient(ghinstance.RESTPrefix(host), httpClient, githubrest.WithoutToken()) -} - -func repoExists(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { - url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(repo.RepoHost()), "repos", repo.RepoOwner(), repo.RepoName()) +func repoExists(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface) (bool, error) { + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName()) if err != nil { return false, err } - req, err := http.NewRequest(http.MethodGet, url.String(), nil) + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) if err != nil { 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 { + // Send is used rather than Do because this check is about the status code: + // it returns a non-nil response even for the *ErrorResponse a 404 produces, + // and the caller must close the body. + resp, err := client.Send(req) + if resp != nil { + defer resp.Body.Close() + } + if resp == nil { return false, err } - defer resp.Body.Close() switch resp.StatusCode { case http.StatusOK: @@ -50,7 +42,7 @@ func repoExists(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { case http.StatusNotFound: return false, nil default: - return false, githubrest.NewErrorResponse(resp) + return false, githubrest.NewErrorResponse(resp.Response) } } @@ -88,25 +80,23 @@ type release struct { } // downloadAsset downloads a single asset to the given file path. -func downloadAsset(httpClient *http.Client, assetURL safeurl.SafeURL, destPath string) (downloadErr error) { - var req *http.Request - if req, downloadErr = http.NewRequest("GET", assetURL.String(), nil); downloadErr != nil { - return +// +// The asset URL is a GitHub REST API URL, so it goes through client, whose host +// is credentialed. Send is used rather than Do so a non-2xx status is caught +// before the destination file is created, leaving no empty file behind on +// failure, and because the binary body is streamed straight to disk. +func downloadAsset(ctx context.Context, client *githubrest.Client, assetURL safeurl.SafeURL, destPath string) (downloadErr error) { + req, err := client.NewRequest(ctx, http.MethodGet, assetURL.String(), nil, githubrest.WithHeader("Accept", "application/octet-stream")) + if err != nil { + return err } - 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 + resp, err := client.Send(req) + if resp != nil { + defer resp.Body.Close() } - defer resp.Body.Close() - - if resp.StatusCode > 299 { - downloadErr = githubrest.NewErrorResponse(resp) - return + if err != nil { + return err } var f *os.File @@ -184,36 +174,27 @@ func fetchReleaseFromTag(ctx context.Context, client *githubrest.Client, baseRep } // fetchCommitSHA finds full commit SHA from a target ref in a repo -func fetchCommitSHA(httpClient *http.Client, baseRepo ghrepo.Interface, targetRef string) (string, error) { - url, err := safeurl.JoinPathWithHostPrefix(ghinstance.RESTPrefix(baseRepo.RepoHost()), "repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "commits", targetRef) - if err != nil { - return "", err - } - req, err := http.NewRequest("GET", url.String(), nil) +func fetchCommitSHA(ctx context.Context, client *githubrest.Client, baseRepo ghrepo.Interface, targetRef string) (string, error) { + path, err := safeurl.JoinPath("repos", baseRepo.RepoOwner(), baseRepo.RepoName(), "commits", targetRef) if err != nil { return "", err } - 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) + // The v3.sha media type makes the endpoint return the bare SHA as the body, + // so it is streamed into a buffer rather than decoded as JSON. + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil, githubrest.WithHeader("Accept", "application/vnd.github.v3.sha")) if err != nil { return "", err } - defer resp.Body.Close() - if resp.StatusCode == 422 { - return "", commitNotFoundErr - } - if resp.StatusCode > 299 { - return "", githubrest.NewErrorResponse(resp) - } - - body, err := io.ReadAll(resp.Body) + var body bytes.Buffer + resp, err := client.Do(req, &body) if err != nil { + if resp != nil && resp.StatusCode == http.StatusUnprocessableEntity { + return "", commitNotFoundErr + } return "", err } - return string(body), nil + return body.String(), nil } diff --git a/pkg/cmd/extension/http_test.go b/pkg/cmd/extension/http_test.go index 1f63bf95c97..286be16e830 100644 --- a/pkg/cmd/extension/http_test.go +++ b/pkg/cmd/extension/http_test.go @@ -32,7 +32,7 @@ func extensionHTTPClient(t *testing.T, path string, status int, body string) *ht func extensionRESTClient(t *testing.T, path string, status int, body string) *githubrest.Client { t.Helper() - client, err := restClientFromHTTP(extensionHTTPClient(t, path, status, body), "github.com") + client, err := httpmock.RESTClientFunc(extensionHTTPClient(t, path, status, body).Transport)("github.com") require.NoError(t, err) return client } @@ -55,9 +55,9 @@ func TestRepoExists(t *testing.T) { "non-JSON body": "repository", } { t.Run("success with "+name, func(t *testing.T) { - client := extensionHTTPClient(t, "repos/OWNER/REPO", http.StatusOK, body) + client := extensionRESTClient(t, "repos/OWNER/REPO", http.StatusOK, body) - exists, err := repoExists(client, repo) + exists, err := repoExists(t.Context(), client, repo) require.NoError(t, err) assert.True(t, exists) @@ -65,18 +65,18 @@ func TestRepoExists(t *testing.T) { } t.Run("not found", func(t *testing.T) { - client := extensionHTTPClient(t, "repos/OWNER/REPO", http.StatusNotFound, `{"message":"Not Found"}`) + client := extensionRESTClient(t, "repos/OWNER/REPO", http.StatusNotFound, `{"message":"Not Found"}`) - exists, err := repoExists(client, repo) + exists, err := repoExists(t.Context(), 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"}`) + client := extensionRESTClient(t, "repos/OWNER/REPO", http.StatusInternalServerError, `{"message":"Internal Server Error"}`) - exists, err := repoExists(client, repo) + exists, err := repoExists(t.Context(), client, repo) assert.False(t, exists) requireExtensionHTTPError(t, err, http.StatusInternalServerError) @@ -84,9 +84,9 @@ func TestRepoExists(t *testing.T) { 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"}`) + client := extensionRESTClient(t, "repos/OWNER/REPO", status, `{"message":"Unexpected status"}`) - exists, err := repoExists(client, repo) + exists, err := repoExists(t.Context(), client, repo) assert.False(t, exists) requireExtensionHTTPError(t, err, status) diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index faaf40c717a..8ddb0be184b 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -21,6 +21,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/findsh" @@ -43,6 +44,12 @@ func (e *ErrExtensionExecutableNotFound) Error() string { const darwinAmd64 = "darwin-amd64" +// restClientFactory builds a githubrest.Client for host with the token already +// resolved, mirroring cmdutil.Factory.GitHubREST. The manager holds the factory +// rather than a single client so it can build one per host it touches, and so +// the token is settled at construction rather than injected per request. +type restClientFactory func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) + type Manager struct { dataDir func() string updateDir func() string @@ -51,6 +58,7 @@ type Manager struct { newCommand func(string, ...string) *exec.Cmd platform func() (string, string) client *http.Client + gitHubREST restClientFactory gitClient gitClient config gh.Config io *iostreams.IOStreams @@ -86,6 +94,12 @@ func (m *Manager) SetClient(client *http.Client) { m.client = client } +// SetGitHubREST wires the factory the manager uses to build githubrest.Clients +// for REST calls, which is cmdutil.Factory.GitHubREST in production. +func (m *Manager) SetGitHubREST(f restClientFactory) { + m.gitHubREST = f +} + func (m *Manager) EnableDryRunMode() { m.dryRunMode = true } @@ -165,7 +179,7 @@ func (m *Manager) list(includeMetadata bool) ([]*Extension, error) { results = append(results, &Extension{ path: filepath.Join(dir, f.Name(), f.Name()), kind: BinaryKind, - httpClient: m.client, + gitHubREST: m.gitHubREST, }) } else { results = append(results, &Extension{ @@ -253,10 +267,18 @@ type binManifest struct { // Install installs an extension from repo, and pins to commitish if provided func (m *Manager) Install(repo ghrepo.Interface, target string) error { - isBin, err := isBinExtension(m.client, repo) + // The ExtensionManager interface method carries no context and widening it + // is out of scope, so a background context is used here. + ctx := context.Background() + restClient, err := m.gitHubREST(repo.RepoHost()) + if err != nil { + return err + } + + isBin, err := isBinExtension(ctx, restClient, repo) if err != nil { if errors.Is(err, releaseNotFoundErr) { - if ok, err := repoExists(m.client, repo); err != nil { + if ok, err := repoExists(ctx, restClient, repo); err != nil { return err } else if !ok { return repositoryNotFoundErr @@ -269,13 +291,7 @@ func (m *Manager) Install(repo ghrepo.Interface, target string) error { return m.installBin(repo, target) } - restClient, err := restClientFromHTTP(m.client, repo.RepoHost()) - if err != nil { - return err - } - // The ExtensionManager interface method carries no context and widening it - // is out of scope, so a background context is used here. - hs, err := hasScript(context.Background(), restClient, repo) + hs, err := hasScript(ctx, restClient, repo) if err != nil { return err } @@ -288,18 +304,18 @@ func (m *Manager) Install(repo ghrepo.Interface, target string) error { func (m *Manager) installBin(repo ghrepo.Interface, target string) error { var r *release - var err error - restClient, err := restClientFromHTTP(m.client, repo.RepoHost()) + // The ExtensionManager interface method carries no context and widening it + // is out of scope, so a background context is used here. + ctx := context.Background() + restClient, err := m.gitHubREST(repo.RepoHost()) if err != nil { return err } isPinned := target != "" - // The ExtensionManager interface method carries no context and widening it - // is out of scope, so a background context is used here. if isPinned { - r, err = fetchReleaseFromTag(context.Background(), restClient, repo, target) + r, err = fetchReleaseFromTag(ctx, restClient, repo, target) } else { - r, err = fetchLatestRelease(context.Background(), restClient, repo) + r, err = fetchLatestRelease(ctx, restClient, repo) } if err != nil { return err @@ -364,7 +380,7 @@ func (m *Manager) installBin(repo ghrepo.Interface, target string) error { binPath := filepath.Join(targetDir, name) binPath += ext - err = downloadAsset(m.client, safeurl.NewImmutableSafeURL(asset.APIURL), binPath) + err = downloadAsset(ctx, restClient, safeurl.NewImmutableSafeURL(asset.APIURL), binPath) if err != nil { return fmt.Errorf("failed to download asset %s: %w", asset.Name, err) } @@ -428,8 +444,13 @@ func (m *Manager) installGit(repo ghrepo.Interface, target string) error { var commitSHA string if target != "" { - var err error - commitSHA, err = fetchCommitSHA(m.client, repo, target) + restClient, err := m.gitHubREST(repo.RepoHost()) + if err != nil { + return err + } + // The ExtensionManager interface method carries no context and widening + // it is out of scope, so a background context is used here. + commitSHA, err = fetchCommitSHA(context.Background(), restClient, repo, target) if err != nil { return err } @@ -548,7 +569,11 @@ func (m *Manager) upgradeExtension(ext *Extension, force bool) error { var isBin bool repo, repoErr := repoFromPath(m.gitClient, filepath.Join(ext.Path(), "..")) if repoErr == nil { - isBin, _ = isBinExtension(m.client, repo) + if restClient, err := m.gitHubREST(repo.RepoHost()); err == nil { + // The ExtensionManager interface method carries no context and + // widening it is out of scope, so a background context is used here. + isBin, _ = isBinExtension(context.Background(), restClient, repo) + } } if isBin { if err := m.Remove(ext.Name()); err != nil { @@ -758,16 +783,9 @@ func readPathFromFile(path string) (string, error) { return strings.TrimSpace(string(b[:n])), err } -func isBinExtension(httpClient *http.Client, repo ghrepo.Interface) (isBin bool, err error) { - restClient, err := restClientFromHTTP(httpClient, repo.RepoHost()) - if err != nil { - return false, err - } +func isBinExtension(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface) (isBin bool, err error) { var r *release - // This is called from ExtensionManager interface methods that carry no - // context and widening them is out of scope, so a background context is - // used here. - r, err = fetchLatestRelease(context.Background(), restClient, repo) + r, err = fetchLatestRelease(ctx, client, repo) if err != nil { return } diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index 567f3dba3ba..9f34e6c7526 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -46,6 +46,13 @@ func TestHelperProcess(t *testing.T) { } func newTestManager(dataDir, updateDir string, client *http.Client, gitClient gitClient, ios *iostreams.IOStreams, extraEnv ...string) *Manager { + var gitHubREST restClientFactory + if client != nil { + // Mirror the production wiring, which resolves the token at construction + // rather than injecting it per request. The transport is whatever the + // test's *http.Client wraps, which is usually a *httpmock.Registry. + gitHubREST = httpmock.RESTClientFunc(client.Transport) + } return &Manager{ dataDir: func() string { return dataDir }, updateDir: func() string { return updateDir }, @@ -61,10 +68,11 @@ func newTestManager(dataDir, updateDir string, client *http.Client, gitClient gi cmd.Env = append([]string{"GH_WANT_HELPER_PROCESS=1"}, extraEnv...) return cmd }, - config: config.NewBlankConfig(), - io: ios, - client: client, - gitClient: gitClient, + config: config.NewBlankConfig(), + io: ios, + client: client, + gitHubREST: gitHubREST, + gitClient: gitClient, platform: func() (string, string) { return "windows-amd64", ".exe" }, diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 1e0b5a7b4ee..963f03a3a34 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -358,6 +358,7 @@ func branchFunc(f *cmdutil.Factory) func() (string, error) { func extensionManager(f *cmdutil.Factory) *extension.Manager { em := extension.NewManager(f.IOStreams, f.GitClient) + em.SetGitHubREST(f.GitHubREST) cfg, err := f.Config() if err != nil { From d4bd4c6551cf5b21468700c6b3e21f96e686e332 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 17:02:47 +0200 Subject: [PATCH 19/21] Give REST its own repository type instead of returning the GraphQL one The REST repo, license and gitignore calls lived in pkg/cmd/*/shared, which meant pr/create had to reach into pkg/cmd/repo/shared for ForkRepo. Move them into githubrest, alongside the client that issues them. They returned *api.Repository, the ~70 field GraphQL type, populated with the five fields a v3 response carries. githubrest now has its own Repository, and License and GitIgnore move across whole since they are REST-only shapes with no GraphQL equivalent. That keeps githubrest free of any dependency on api. repoCreate still needs an api.Repository, because its other branch is a GraphQL mutation that returns one, so the mapping lives there and nowhere else. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- api/queries_repo.go | 21 -- .../githubrest}/branch_test.go | 5 +- internal/githubrest/pull_requests.go | 83 ++++++ internal/githubrest/repositories.go | 253 ++++++++++++++++++ .../githubrest/repositories_test.go | 43 ++- pkg/cmd/pr/close/close.go | 2 +- pkg/cmd/pr/create/create.go | 3 +- pkg/cmd/pr/edit/edit.go | 4 +- pkg/cmd/pr/edit/reviewers_rest.go | 114 -------- pkg/cmd/pr/merge/merge.go | 2 +- pkg/cmd/pr/shared/branch.go | 25 -- pkg/cmd/repo/create/create.go | 8 +- pkg/cmd/repo/create/http.go | 22 +- pkg/cmd/repo/edit/edit.go | 9 +- pkg/cmd/repo/fork/fork.go | 4 +- pkg/cmd/repo/gitignore/list/list.go | 3 +- pkg/cmd/repo/gitignore/view/view.go | 6 +- pkg/cmd/repo/license/list/list.go | 6 +- pkg/cmd/repo/license/view/view.go | 6 +- pkg/cmd/repo/rename/rename.go | 3 +- pkg/cmd/repo/shared/http.go | 217 --------------- 21 files changed, 396 insertions(+), 443 deletions(-) rename {pkg/cmd/pr/shared => internal/githubrest}/branch_test.go (89%) create mode 100644 internal/githubrest/pull_requests.go create mode 100644 internal/githubrest/repositories.go rename pkg/cmd/repo/shared/http_test.go => internal/githubrest/repositories_test.go (91%) delete mode 100644 pkg/cmd/pr/edit/reviewers_rest.go delete mode 100644 pkg/cmd/pr/shared/branch.go delete mode 100644 pkg/cmd/repo/shared/http.go diff --git a/api/queries_repo.go b/api/queries_repo.go index c060f902ae7..c0ba43f6fef 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -226,27 +226,6 @@ type IssueLabel struct { Color string `json:"color"` } -type License struct { - Key string `json:"key"` - Name string `json:"name"` - SPDXID string `json:"spdx_id"` - URL string `json:"url"` - NodeID string `json:"node_id"` - HTMLURL string `json:"html_url"` - Description string `json:"description"` - Implementation string `json:"implementation"` - Permissions []string `json:"permissions"` - Conditions []string `json:"conditions"` - Limitations []string `json:"limitations"` - Body string `json:"body"` - Featured bool `json:"featured"` -} - -type GitIgnore struct { - Name string `json:"name"` - Source string `json:"source"` -} - // RepoOwner is the login name of the owner func (r Repository) RepoOwner() string { return r.Owner.Login diff --git a/pkg/cmd/pr/shared/branch_test.go b/internal/githubrest/branch_test.go similarity index 89% rename from pkg/cmd/pr/shared/branch_test.go rename to internal/githubrest/branch_test.go index 4072e879aea..69d12c23f34 100644 --- a/pkg/cmd/pr/shared/branch_test.go +++ b/internal/githubrest/branch_test.go @@ -1,10 +1,11 @@ -package shared +package githubrest_test import ( "context" "testing" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" "github.com/stretchr/testify/require" ) @@ -49,7 +50,7 @@ func TestBranchDeleteRemote(t *testing.T) { require.NoError(t, err) repo, _ := ghrepo.FromFullName("OWNER/REPO") - err = BranchDeleteRemote(context.Background(), client, repo, tt.branch) + err = githubrest.BranchDeleteRemote(context.Background(), client, repo, tt.branch) if (err != nil) != tt.expectError { t.Fatalf("unexpected result: %v", err) } diff --git a/internal/githubrest/pull_requests.go b/internal/githubrest/pull_requests.go new file mode 100644 index 00000000000..aa1e91a19bd --- /dev/null +++ b/internal/githubrest/pull_requests.go @@ -0,0 +1,83 @@ +package githubrest + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" +) + +// reviewRequest is the request body shared by adding and removing review +// requests. The API rejects null in place of an empty array. +type reviewRequest struct { + Reviewers []string `json:"reviewers"` + TeamReviewers []string `json:"team_reviewers"` +} + +// AddPullRequestReviews requests reviews from the given users and teams. +// Team identifiers may be in "org/slug" format. +func AddPullRequestReviews(ctx context.Context, client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { + return changeReviewRequests(ctx, client, http.MethodPost, repo, prNumber, users, teams) +} + +// RemovePullRequestReviews withdraws review requests from the given users and +// teams. Team identifiers may be in "org/slug" format. +func RemovePullRequestReviews(ctx context.Context, client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { + return changeReviewRequests(ctx, client, http.MethodDelete, repo, prNumber, users, teams) +} + +func changeReviewRequests(ctx context.Context, client *Client, method string, repo ghrepo.Interface, prNumber int, users, teams []string) error { + if len(users) == 0 && len(teams) == 0 { + return nil + } + if users == nil { + users = []string{} + } + + path, err := safeurl.JoinPath( + "repos", + repo.RepoOwner(), + repo.RepoName(), + "pulls", + strconv.Itoa(prNumber), + "requested_reviewers", + ) + if err != nil { + return err + } + + buf := &bytes.Buffer{} + if err := json.NewEncoder(buf).Encode(reviewRequest{ + Reviewers: users, + TeamReviewers: extractTeamSlugs(teams), + }); err != nil { + return err + } + + req, err := client.NewRequest(ctx, method, path.String(), buf) + if err != nil { + return err + } + // The endpoint responds with the updated pull request object; no caller needs it. + _, err = client.Do(req, nil) + return err +} + +// extractTeamSlugs returns just the slug portion of team identifiers, which may +// be given as "org/slug". +func extractTeamSlugs(teams []string) []string { + slugs := make([]string, 0, len(teams)) + for _, t := range teams { + if t == "" { + continue + } + s := strings.SplitN(t, "/", 2) + slugs = append(slugs, s[len(s)-1]) + } + return slugs +} diff --git a/internal/githubrest/repositories.go b/internal/githubrest/repositories.go new file mode 100644 index 00000000000..ed2842a72e8 --- /dev/null +++ b/internal/githubrest/repositories.go @@ -0,0 +1,253 @@ +package githubrest + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/safeurl" +) + +// Repository is a repository as returned by the REST API. +// +// It is deliberately separate from api.Repository, which models the far larger +// GraphQL schema. Callers that need the GraphQL shape map between the two at +// the point where they need it, rather than this package depending on api. +type Repository struct { + NodeID string `json:"node_id"` + Name string `json:"name"` + CreatedAt time.Time `json:"created_at"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` + Private bool `json:"private"` + HTMLURL string `json:"html_url"` + Parent *Repository `json:"parent"` + + // Hostname is not part of the API response. It is filled in from the host + // the request was made against so that Repository satisfies ghrepo.Interface. + Hostname string `json:"-"` +} + +func (r Repository) RepoName() string { return r.Name } +func (r Repository) RepoOwner() string { return r.Owner.Login } +func (r Repository) RepoHost() string { return r.Hostname } + +// setHostname records the host a repository was fetched from, including on the +// parent, which the API returns without any indication of its host. +func (r *Repository) setHostname(hostname string) *Repository { + r.Hostname = hostname + if r.Parent != nil { + r.Parent.Hostname = hostname + } + return r +} + +// License is a license as returned by the REST API. Licenses have no GraphQL +// equivalent, which is why this endpoint exists at all. +type License struct { + Key string `json:"key"` + Name string `json:"name"` + SPDXID string `json:"spdx_id"` + URL string `json:"url"` + NodeID string `json:"node_id"` + HTMLURL string `json:"html_url"` + Description string `json:"description"` + Implementation string `json:"implementation"` + Permissions []string `json:"permissions"` + Conditions []string `json:"conditions"` + Limitations []string `json:"limitations"` + Body string `json:"body"` + Featured bool `json:"featured"` +} + +// GitIgnore is a gitignore template as returned by the REST API. Templates have +// no GraphQL equivalent. +type GitIgnore struct { + Name string `json:"name"` + Source string `json:"source"` +} + +// ForkRepo forks the repository on GitHub and returns the new repository. +func ForkRepo(ctx context.Context, client *Client, repo ghrepo.Interface, org, newName string, defaultBranchOnly bool) (*Repository, error) { + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "forks") + if err != nil { + return nil, err + } + + params := map[string]interface{}{} + if org != "" { + params["organization"] = org + } + if newName != "" { + params["name"] = newName + } + if defaultBranchOnly { + params["default_branch_only"] = true + } + + body := &bytes.Buffer{} + if err := json.NewEncoder(body).Encode(params); err != nil { + return nil, err + } + + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), body) + if err != nil { + return nil, err + } + + var forked Repository + if _, err := client.Do(req, &forked); err != nil { + return nil, err + } + forked.setHostname(repo.RepoHost()) + + // The GitHub API will happily return a HTTP 200 when attempting to fork own repo even though no forking + // actually took place. Ensure that we raise an error instead. + if ghrepo.IsSame(repo, &forked) { + return &forked, fmt.Errorf("%s cannot be forked. A single user account cannot own both a parent and fork.", ghrepo.FullName(repo)) + } + + return &forked, nil +} + +// RenameRepo renames the repository on GitHub and returns the renamed repository. +func RenameRepo(ctx context.Context, client *Client, repo ghrepo.Interface, newRepoName string) (*Repository, error) { + body := &bytes.Buffer{} + if err := json.NewEncoder(body).Encode(map[string]string{"name": newRepoName}); err != nil { + return nil, err + } + + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName()) + if err != nil { + return nil, err + } + + req, err := client.NewRequest(ctx, http.MethodPatch, path.String(), body) + if err != nil { + return nil, err + } + + var renamed Repository + if _, err := client.Do(req, &renamed); err != nil { + return nil, err + } + return renamed.setHostname(repo.RepoHost()), nil +} + +// CreateRepo creates a repository. The caller supplies the path because the +// endpoint differs between user-owned and organization-owned repositories. +func CreateRepo(ctx context.Context, client *Client, hostname string, path safeurl.SafeURL, body io.Reader) (*Repository, error) { + req, err := client.NewRequest(ctx, http.MethodPost, path.String(), body) + if err != nil { + return nil, err + } + + var created Repository + if _, err := client.Do(req, &created); err != nil { + return nil, err + } + return created.setHostname(hostname), nil +} + +// EditRepo applies a partial update to a repository. The response is discarded +// because no caller needs it. +func EditRepo(ctx context.Context, client *Client, repo ghrepo.Interface, body io.Reader) error { + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName()) + if err != nil { + return err + } + req, err := client.NewRequest(ctx, http.MethodPatch, path.String(), body) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err +} + +// BranchDeleteRemote deletes a branch ref on the given repository. +func BranchDeleteRemote(ctx context.Context, client *Client, repo ghrepo.Interface, branch string) error { + path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "git", "refs", fmt.Sprintf("heads/%s", branch)) + if err != nil { + return err + } + req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), nil) + if err != nil { + return err + } + _, err = client.Do(req, nil) + return err +} + +// RepoLicenses fetches the licenses GitHub can apply to a new repository. +func RepoLicenses(ctx context.Context, client *Client) ([]License, error) { + path, err := safeurl.JoinPath("licenses") + if err != nil { + return nil, err + } + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) + if err != nil { + return nil, err + } + var licenses []License + if _, err := client.Do(req, &licenses); err != nil { + return nil, err + } + return licenses, nil +} + +// RepoLicense fetches a single license by its key. +func RepoLicense(ctx context.Context, client *Client, licenseName string) (*License, error) { + path, err := safeurl.JoinPath("licenses", licenseName) + if err != nil { + return nil, err + } + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) + if err != nil { + return nil, err + } + var license License + if _, err := client.Do(req, &license); err != nil { + return nil, err + } + return &license, nil +} + +// RepoGitIgnoreTemplates fetches the names of the available gitignore templates. +func RepoGitIgnoreTemplates(ctx context.Context, client *Client) ([]string, error) { + path, err := safeurl.JoinPath("gitignore", "templates") + if err != nil { + return nil, err + } + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) + if err != nil { + return nil, err + } + var templates []string + if _, err := client.Do(req, &templates); err != nil { + return nil, err + } + return templates, nil +} + +// RepoGitIgnoreTemplate fetches a single gitignore template by name. +func RepoGitIgnoreTemplate(ctx context.Context, client *Client, name string) (*GitIgnore, error) { + path, err := safeurl.JoinPath("gitignore", "templates", name) + if err != nil { + return nil, err + } + req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) + if err != nil { + return nil, err + } + var template GitIgnore + if _, err := client.Do(req, &template); err != nil { + return nil, err + } + return &template, nil +} diff --git a/pkg/cmd/repo/shared/http_test.go b/internal/githubrest/repositories_test.go similarity index 91% rename from pkg/cmd/repo/shared/http_test.go rename to internal/githubrest/repositories_test.go index e3affb54d8f..9d7865d49a4 100644 --- a/pkg/cmd/repo/shared/http_test.go +++ b/internal/githubrest/repositories_test.go @@ -1,4 +1,4 @@ -package shared +package githubrest_test import ( "context" @@ -6,7 +6,6 @@ import ( "testing" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/pkg/httpmock" @@ -26,11 +25,9 @@ func TestForkRepoReturnsErrorWhenForkIsNotPossible(t *testing.T) { // the repo we provided repoName := "test-repo" ownerLogin := "test-owner" - stubbedForkResponse := repositoryV3{ - Name: repoName, - Owner: struct{ Login string }{ - Login: ownerLogin, - }, + stubbedForkResponse := map[string]any{ + "name": repoName, + "owner": map[string]any{"login": ownerLogin}, } reg := &httpmock.Registry{} @@ -42,14 +39,13 @@ func TestForkRepoReturnsErrorWhenForkIsNotPossible(t *testing.T) { client := newTestRESTClient(t, reg) // When we fork the repo - _, err := ForkRepo(context.Background(), client, ghrepo.New(ownerLogin, repoName), ownerLogin, "", false) + _, err := githubrest.ForkRepo(context.Background(), client, ghrepo.New(ownerLogin, repoName), ownerLogin, "", false) // Then it provides a useful error message require.Equal(t, fmt.Errorf("%s/%s cannot be forked. A single user account cannot own both a parent and fork.", ownerLogin, repoName), err) } -func TestListLicenseTemplatesReturnsLicenses(t *testing.T) { - hostname := "api.github.com" +func TestListLicenseTemplatesReturnsRepoLicenses(t *testing.T) { httpStubs := func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "licenses"), @@ -106,7 +102,7 @@ func TestListLicenseTemplatesReturnsLicenses(t *testing.T) { ]`, )) } - wantLicenses := []api.License{ + wantLicenses := []githubrest.License{ { Key: "mit", Name: "MIT License", @@ -213,15 +209,14 @@ func TestListLicenseTemplatesReturnsLicenses(t *testing.T) { client := newTestRESTClient(t, reg) - gotLicenses, err := RepoLicenses(context.Background(), client, hostname) + gotLicenses, err := githubrest.RepoLicenses(context.Background(), client) assert.NoError(t, err, "Expected no error while fetching /licenses") assert.Equal(t, wantLicenses, gotLicenses, "Licenses fetched is not as expected") } -func TestLicenseTemplateReturnsLicense(t *testing.T) { +func TestLicenseTemplateReturnsRepoLicense(t *testing.T) { licenseTemplateName := "mit" - hostname := "api.github.com" httpStubs := func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", fmt.Sprintf("licenses/%v", licenseTemplateName)), @@ -252,7 +247,7 @@ func TestLicenseTemplateReturnsLicense(t *testing.T) { }`, )) } - wantLicense := &api.License{ + wantLicense := &githubrest.License{ Key: "mit", Name: "MIT License", SPDXID: "MIT", @@ -284,7 +279,7 @@ func TestLicenseTemplateReturnsLicense(t *testing.T) { client := newTestRESTClient(t, reg) - gotLicenseTemplate, err := RepoLicense(context.Background(), client, hostname, licenseTemplateName) + gotLicenseTemplate, err := githubrest.RepoLicense(context.Background(), client, licenseTemplateName) assert.NoError(t, err, fmt.Sprintf("Expected no error while fetching /licenses/%v", licenseTemplateName)) assert.Equal(t, wantLicense, gotLicenseTemplate, fmt.Sprintf("License \"%v\" fetched is not as expected", licenseTemplateName)) @@ -292,7 +287,6 @@ func TestLicenseTemplateReturnsLicense(t *testing.T) { func TestLicenseTemplateReturnsErrorWhenLicenseTemplateNotFound(t *testing.T) { licenseTemplateName := "invalid-license" - hostname := "api.github.com" httpStubs := func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", fmt.Sprintf("licenses/%v", licenseTemplateName)), @@ -311,13 +305,12 @@ func TestLicenseTemplateReturnsErrorWhenLicenseTemplateNotFound(t *testing.T) { client := newTestRESTClient(t, reg) - _, err := RepoLicense(context.Background(), client, hostname, licenseTemplateName) + _, err := githubrest.RepoLicense(context.Background(), client, licenseTemplateName) assert.Error(t, err, fmt.Sprintf("Expected error while fetching /licenses/%v", licenseTemplateName)) } -func TestListGitIgnoreTemplatesReturnsGitIgnoreTemplates(t *testing.T) { - hostname := "api.github.com" +func TestListGitIgnoreTemplatesReturnsRepoGitIgnoreTemplates(t *testing.T) { httpStubs := func(reg *httpmock.Registry) { reg.Register( httpmock.REST("GET", "gitignore/templates"), @@ -384,13 +377,13 @@ func TestListGitIgnoreTemplatesReturnsGitIgnoreTemplates(t *testing.T) { client := newTestRESTClient(t, reg) - gotGitIgnoreTemplates, err := RepoGitIgnoreTemplates(context.Background(), client, hostname) + gotGitIgnoreTemplates, err := githubrest.RepoGitIgnoreTemplates(context.Background(), client) assert.NoError(t, err, "Expected no error while fetching /gitignore/templates") assert.Equal(t, wantGitIgnoreTemplates, gotGitIgnoreTemplates, "GitIgnore templates fetched is not as expected") } -func TestGitIgnoreTemplateReturnsGitIgnoreTemplate(t *testing.T) { +func TestGitIgnoreTemplateReturnsRepoGitIgnoreTemplate(t *testing.T) { gitIgnoreTemplateName := "Go" httpStubs := func(reg *httpmock.Registry) { reg.Register( @@ -401,7 +394,7 @@ func TestGitIgnoreTemplateReturnsGitIgnoreTemplate(t *testing.T) { }`, )) } - wantGitIgnoreTemplate := &api.GitIgnore{ + wantGitIgnoreTemplate := &githubrest.GitIgnore{ Name: "Go", Source: "# If you prefer the allow list template instead of the deny list, see community template:\n# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore\n#\n# Binaries for programs and plugins\n*.exe\n*.exe~\n*.dll\n*.so\n*.dylib\n\n# Test binary, built with go test -c\n*.test\n\n# Output of the go coverage tool, specifically when used with LiteIDE\n*.out\n\n# Dependency directories (remove the comment below to include it)\n# vendor/\n\n# Go workspace file\ngo.work\ngo.work.sum\n\n# env file\n.env\n", } @@ -412,7 +405,7 @@ func TestGitIgnoreTemplateReturnsGitIgnoreTemplate(t *testing.T) { client := newTestRESTClient(t, reg) - gotGitIgnoreTemplate, err := RepoGitIgnoreTemplate(context.Background(), client, "api.github.com", gitIgnoreTemplateName) + gotGitIgnoreTemplate, err := githubrest.RepoGitIgnoreTemplate(context.Background(), client, gitIgnoreTemplateName) assert.NoError(t, err, fmt.Sprintf("Expected no error while fetching /gitignore/templates/%v", gitIgnoreTemplateName)) assert.Equal(t, wantGitIgnoreTemplate, gotGitIgnoreTemplate, fmt.Sprintf("GitIgnore template \"%v\" fetched is not as expected", gitIgnoreTemplateName)) @@ -438,7 +431,7 @@ func TestGitIgnoreTemplateReturnsErrorWhenGitIgnoreTemplateNotFound(t *testing.T client := newTestRESTClient(t, reg) - _, err := RepoGitIgnoreTemplate(context.Background(), client, "api.github.com", gitIgnoreTemplateName) + _, err := githubrest.RepoGitIgnoreTemplate(context.Background(), client, gitIgnoreTemplateName) assert.Error(t, err, fmt.Sprintf("Expected error while fetching /gitignore/templates/%v", gitIgnoreTemplateName)) } diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go index 3770d98971b..dc13a3ffb88 100644 --- a/pkg/cmd/pr/close/close.go +++ b/pkg/cmd/pr/close/close.go @@ -160,7 +160,7 @@ func closeRun(opts *CloseOptions) error { if err != nil { return err } - if err := shared.BranchDeleteRemote(ctx, restClient, baseRepo, pr.HeadRefName); err != nil { + if err := githubrest.BranchDeleteRemote(ctx, restClient, baseRepo, pr.HeadRefName); err != nil { return fmt.Errorf("failed to delete remote branch %s: %w", cs.Cyan(pr.HeadRefName), err) } } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 818ec5a888f..c0e8d9cd0f2 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -25,7 +25,6 @@ import ( "github.com/cli/cli/v2/internal/prompter" "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/cmd/pr/shared" - reposhared "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/markdown" @@ -1179,7 +1178,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error { return err } opts.IO.StartProgressIndicator() - forkedRepo, err := reposhared.ForkRepo(context.Background(), restClient, forkableRefs.BaseRepo(), "", "", true) + forkedRepo, err := githubrest.ForkRepo(context.Background(), restClient, forkableRefs.BaseRepo(), "", "", true) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("error forking repo: %w", err) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 77ea5e634bc..3d2fae486f7 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -504,10 +504,10 @@ func updatePullRequestReviewsREST(ctx context.Context, client *githubrest.Client wg := errgroup.Group{} wg.Go(func() error { - return addPullRequestReviews(ctx, client, repo, number, allAddUsers, addTeams) + return githubrest.AddPullRequestReviews(ctx, client, repo, number, allAddUsers, addTeams) }) wg.Go(func() error { - return removePullRequestReviews(ctx, client, repo, number, allRemoveUsers, removeTeams) + return githubrest.RemovePullRequestReviews(ctx, client, repo, number, allRemoveUsers, removeTeams) }) return wg.Wait() } diff --git a/pkg/cmd/pr/edit/reviewers_rest.go b/pkg/cmd/pr/edit/reviewers_rest.go deleted file mode 100644 index 4e263f9fceb..00000000000 --- a/pkg/cmd/pr/edit/reviewers_rest.go +++ /dev/null @@ -1,114 +0,0 @@ -package edit - -import ( - "bytes" - "context" - "encoding/json" - "net/http" - "strconv" - "strings" - - "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/githubrest" - "github.com/cli/cli/v2/internal/safeurl" -) - -// addPullRequestReviews adds the given user and team reviewers to a pull request using the REST API. -// Team identifiers can be in "org/slug" format. -func addPullRequestReviews(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { - if len(users) == 0 && len(teams) == 0 { - return nil - } - - // The API requires empty arrays instead of null values - if users == nil { - users = []string{} - } - - path, err := safeurl.JoinPath( - "repos", - repo.RepoOwner(), - repo.RepoName(), - "pulls", - strconv.Itoa(prNumber), - "requested_reviewers", - ) - if err != nil { - return err - } - body := struct { - Reviewers []string `json:"reviewers"` - TeamReviewers []string `json:"team_reviewers"` - }{ - Reviewers: users, - TeamReviewers: extractTeamSlugs(teams), - } - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(body); err != nil { - return err - } - req, err := client.NewRequest(ctx, http.MethodPost, path.String(), buf) - if err != nil { - return err - } - // The endpoint responds with the updated pull request object; we don't need it here. - _, err = client.Do(req, nil) - return err -} - -// removePullRequestReviews removes requested reviewers from a pull request using the REST API. -// Team identifiers can be in "org/slug" format. -func removePullRequestReviews(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, prNumber int, users, teams []string) error { - if len(users) == 0 && len(teams) == 0 { - return nil - } - - // The API requires empty arrays instead of null values - if users == nil { - users = []string{} - } - - path, err := safeurl.JoinPath( - "repos", - repo.RepoOwner(), - repo.RepoName(), - "pulls", - strconv.Itoa(prNumber), - "requested_reviewers", - ) - if err != nil { - return err - } - body := struct { - Reviewers []string `json:"reviewers"` - TeamReviewers []string `json:"team_reviewers"` - }{ - Reviewers: users, - TeamReviewers: extractTeamSlugs(teams), - } - buf := &bytes.Buffer{} - if err := json.NewEncoder(buf).Encode(body); err != nil { - return err - } - req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), buf) - if err != nil { - return err - } - // The endpoint responds with the updated pull request object; we don't need it here. - _, err = client.Do(req, nil) - return err -} - -// extractTeamSlugs extracts just the slug portion from team identifiers. -// Team identifiers can be in "org/slug" format; this returns just the slug. -func extractTeamSlugs(teams []string) []string { - slugs := make([]string, 0, len(teams)) - for _, t := range teams { - if t == "" { - continue - } - s := strings.SplitN(t, "/", 2) - slugs = append(slugs, s[len(s)-1]) - } - return slugs -} diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 0062fcedceb..ef2e1e8cc47 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -462,7 +462,7 @@ func (m *mergeContext) deleteRemoteBranch() error { } if !m.merged { - err := shared.BranchDeleteRemote(context.Background(), m.restClient, m.baseRepo, m.pr.HeadRefName) + err := githubrest.BranchDeleteRemote(context.Background(), m.restClient, m.baseRepo, m.pr.HeadRefName) if err != nil { // Normally, the API returns 422, with the message "Reference does not exist" // when the branch has already been deleted. It also returns 404 with the same diff --git a/pkg/cmd/pr/shared/branch.go b/pkg/cmd/pr/shared/branch.go deleted file mode 100644 index 85956fe8558..00000000000 --- a/pkg/cmd/pr/shared/branch.go +++ /dev/null @@ -1,25 +0,0 @@ -package shared - -import ( - "context" - "fmt" - "net/http" - - "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/githubrest" - "github.com/cli/cli/v2/internal/safeurl" -) - -// BranchDeleteRemote deletes the remote branch on the given repository. -func BranchDeleteRemote(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, branch string) error { - path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "git", "refs", fmt.Sprintf("heads/%s", branch)) - if err != nil { - return err - } - req, err := client.NewRequest(ctx, http.MethodDelete, path.String(), nil) - if err != nil { - return err - } - _, err = client.Do(req, nil) - return err -} diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index f99f7d87deb..d68df662f94 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -232,7 +232,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co if err != nil { return nil, cobra.ShellCompDirectiveError } - results, err := shared.RepoGitIgnoreTemplates(cmd.Context(), restClient, hostname) + results, err := githubrest.RepoGitIgnoreTemplates(cmd.Context(), restClient) if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -249,7 +249,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co if err != nil { return nil, cobra.ShellCompDirectiveError } - licenses, err := shared.RepoLicenses(cmd.Context(), restClient, hostname) + licenses, err := githubrest.RepoLicenses(cmd.Context(), restClient) if err != nil { return nil, cobra.ShellCompDirectiveError } @@ -876,7 +876,7 @@ func interactiveGitIgnore(ctx context.Context, client *githubrest.Client, hostna return "", nil } - templates, err := shared.RepoGitIgnoreTemplates(ctx, client, hostname) + templates, err := githubrest.RepoGitIgnoreTemplates(ctx, client) if err != nil { return "", err } @@ -895,7 +895,7 @@ func interactiveLicense(ctx context.Context, client *githubrest.Client, hostname return "", nil } - licenses, err := shared.RepoLicenses(ctx, client, hostname) + licenses, err := githubrest.RepoLicenses(ctx, client) if err != nil { return "", err } diff --git a/pkg/cmd/repo/create/http.go b/pkg/cmd/repo/create/http.go index 94c6280acd2..fabcc428648 100644 --- a/pkg/cmd/repo/create/http.go +++ b/pkg/cmd/repo/create/http.go @@ -12,7 +12,6 @@ import ( "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" - "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/shurcooL/githubv4" ) @@ -209,11 +208,13 @@ func repoCreate(ctx context.Context, client *http.Client, restClient *githubrest return nil, err } - repo, err := shared.CreateRepoTransformToV4(ctx, restClient, hostname, "POST", path, body) + created, err := githubrest.CreateRepo(ctx, restClient, hostname, path, body) if err != nil { return nil, err } - return repo, nil + // The GraphQL branch below returns an api.Repository, so the REST result + // has to be mapped onto the same type for callers of repoCreate. + return repositoryFromREST(created), nil } var response struct { @@ -371,3 +372,18 @@ func userAndOrgs(httpClient *http.Client, hostname string) (string, []string, er client := api.NewClientFromHTTP(httpClient) return api.CurrentLoginNameAndOrgs(client, hostname) } + +// repositoryFromREST maps the small REST repository representation onto the +// GraphQL-shaped api.Repository that repoCreate's callers expect. Only the +// fields the REST create response actually populates are set. +func repositoryFromREST(r *githubrest.Repository) *api.Repository { + repo := &api.Repository{ + ID: r.NodeID, + Name: r.Name, + CreatedAt: r.CreatedAt, + Owner: api.RepositoryOwner{Login: r.Owner.Login}, + URL: r.HTMLURL, + IsPrivate: r.Private, + } + return api.InitRepoHostname(repo, r.RepoHost()) +} diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index 4d503149b19..deb195da4f4 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -18,7 +18,6 @@ import ( "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/safeurl" "github.com/cli/cli/v2/internal/text" - reposhared "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/pkg/set" @@ -306,11 +305,6 @@ func editRun(ctx context.Context, opts *EditOptions) error { } } - apiPath, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName()) - if err != nil { - return err - } - body := &bytes.Buffer{} enc := json.NewEncoder(body) if err := enc.Encode(opts.Edits); err != nil { @@ -326,8 +320,7 @@ func editRun(ctx context.Context, opts *EditOptions) error { if body.Len() > 3 { g.Go(func() error { - _, err := reposhared.CreateRepoTransformToV4(ctx, restClient, repo.RepoHost(), "PATCH", apiPath, body) - return err + return githubrest.EditRepo(ctx, restClient, repo, body) }) } diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index d6f3b7c9b5c..86dfde6fe56 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -208,7 +208,7 @@ func forkRun(ctx context.Context, opts *ForkOptions) error { } opts.IO.StartProgressIndicator() - forkedRepo, err := shared.ForkRepo(ctx, restClient, repoToFork, opts.Organization, opts.ForkName, opts.DefaultBranchOnly) + forkedRepo, err := githubrest.ForkRepo(ctx, restClient, repoToFork, opts.Organization, opts.ForkName, opts.DefaultBranchOnly) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to fork: %w", err) @@ -239,7 +239,7 @@ func forkRun(ctx context.Context, opts *ForkOptions) error { // Rename the new repo if necessary if opts.ForkName != "" && !strings.EqualFold(forkedRepo.RepoName(), shared.NormalizeRepoName(opts.ForkName)) { - forkedRepo, err = shared.RenameRepo(ctx, restClient, forkedRepo, opts.ForkName) + forkedRepo, err = githubrest.RenameRepo(ctx, restClient, forkedRepo, opts.ForkName) if err != nil { return fmt.Errorf("could not rename fork: %w", err) } diff --git a/pkg/cmd/repo/gitignore/list/list.go b/pkg/cmd/repo/gitignore/list/list.go index 981fce2e408..5a2fa1be25d 100644 --- a/pkg/cmd/repo/gitignore/list/list.go +++ b/pkg/cmd/repo/gitignore/list/list.go @@ -7,7 +7,6 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" - "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -60,7 +59,7 @@ func listRun(ctx context.Context, opts *ListOptions) error { return err } - gitIgnoreTemplates, err := shared.RepoGitIgnoreTemplates(ctx, client, hostname) + gitIgnoreTemplates, err := githubrest.RepoGitIgnoreTemplates(ctx, client) if err != nil { return err } diff --git a/pkg/cmd/repo/gitignore/view/view.go b/pkg/cmd/repo/gitignore/view/view.go index 01ae4f26691..2884338d748 100644 --- a/pkg/cmd/repo/gitignore/view/view.go +++ b/pkg/cmd/repo/gitignore/view/view.go @@ -7,10 +7,8 @@ import ( "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/githubrest" - "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -88,7 +86,7 @@ func viewRun(ctx context.Context, opts *ViewOptions) error { return err } - gitIgnore, err := shared.RepoGitIgnoreTemplate(ctx, client, hostname, opts.Template) + gitIgnore, err := githubrest.RepoGitIgnoreTemplate(ctx, client, opts.Template) if err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { @@ -102,7 +100,7 @@ func viewRun(ctx context.Context, opts *ViewOptions) error { return renderGitIgnore(gitIgnore, opts) } -func renderGitIgnore(licenseTemplate *api.GitIgnore, opts *ViewOptions) error { +func renderGitIgnore(licenseTemplate *githubrest.GitIgnore, opts *ViewOptions) error { // I wanted to render this in a markdown code block and benefit // from .gitignore syntax highlighting. But, the upstream syntax highlighter // does not currently support .gitignore. diff --git a/pkg/cmd/repo/license/list/list.go b/pkg/cmd/repo/license/list/list.go index 54766b8470f..f44860abc2b 100644 --- a/pkg/cmd/repo/license/list/list.go +++ b/pkg/cmd/repo/license/list/list.go @@ -5,11 +5,9 @@ import ( "fmt" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/tableprinter" - "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -67,7 +65,7 @@ func listRun(ctx context.Context, opts *ListOptions) error { return err } - licenses, err := shared.RepoLicenses(ctx, client, hostname) + licenses, err := githubrest.RepoLicenses(ctx, client) if err != nil { return err } @@ -79,7 +77,7 @@ func listRun(ctx context.Context, opts *ListOptions) error { return renderLicensesTable(licenses, opts) } -func renderLicensesTable(licenses []api.License, opts *ListOptions) error { +func renderLicensesTable(licenses []githubrest.License, opts *ListOptions) error { t := tableprinter.New(opts.IO, tableprinter.WithHeader("LICENSE KEY", "SPDX ID", "LICENSE NAME")) for _, l := range licenses { t.AddField(l.Key) diff --git a/pkg/cmd/repo/license/view/view.go b/pkg/cmd/repo/license/view/view.go index 73fa0e34e9a..8fad5c2f728 100644 --- a/pkg/cmd/repo/license/view/view.go +++ b/pkg/cmd/repo/license/view/view.go @@ -7,12 +7,10 @@ import ( "strings" "github.com/MakeNowJust/heredoc" - "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/browser" "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/githubrest" "github.com/cli/cli/v2/internal/text" - "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -96,7 +94,7 @@ func viewRun(ctx context.Context, opts *ViewOptions) error { return err } - license, err := shared.RepoLicense(ctx, client, hostname, opts.License) + license, err := githubrest.RepoLicense(ctx, client, opts.License) if err != nil { var httpErr *githubrest.ErrorResponse if errors.As(err, &httpErr) { @@ -118,7 +116,7 @@ func viewRun(ctx context.Context, opts *ViewOptions) error { return renderLicense(license, opts) } -func renderLicense(license *api.License, opts *ViewOptions) error { +func renderLicense(license *githubrest.License, opts *ViewOptions) error { cs := opts.IO.ColorScheme() var out strings.Builder if opts.IO.IsStdoutTTY() { diff --git a/pkg/cmd/repo/rename/rename.go b/pkg/cmd/repo/rename/rename.go index 433100901cc..ac16a0f42f7 100644 --- a/pkg/cmd/repo/rename/rename.go +++ b/pkg/cmd/repo/rename/rename.go @@ -11,7 +11,6 @@ import ( "github.com/cli/cli/v2/internal/gh" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/internal/githubrest" - "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -140,7 +139,7 @@ func renameRun(ctx context.Context, opts *RenameOptions) error { return err } - newRepo, err := shared.RenameRepo(ctx, restClient, currRepo, newRepoName) + newRepo, err := githubrest.RenameRepo(ctx, restClient, currRepo, newRepoName) if err != nil { return err } diff --git a/pkg/cmd/repo/shared/http.go b/pkg/cmd/repo/shared/http.go deleted file mode 100644 index 5006c922b52..00000000000 --- a/pkg/cmd/repo/shared/http.go +++ /dev/null @@ -1,217 +0,0 @@ -package shared - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "time" - - "github.com/cli/cli/v2/api" - "github.com/cli/cli/v2/internal/ghrepo" - "github.com/cli/cli/v2/internal/githubrest" - "github.com/cli/cli/v2/internal/safeurl" -) - -// repositoryV3 is the repository result from GitHub API v3. -type repositoryV3 struct { - NodeID string `json:"node_id"` - Name string - CreatedAt time.Time `json:"created_at"` - Owner struct { - Login string - } - Private bool - HTMLUrl string `json:"html_url"` - Parent *repositoryV3 -} - -// ForkRepo forks the repository on GitHub and returns the new repository. -func ForkRepo(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, org, newName string, defaultBranchOnly bool) (*api.Repository, error) { - path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName(), "forks") - if err != nil { - return nil, err - } - - params := map[string]interface{}{} - if org != "" { - params["organization"] = org - } - if newName != "" { - params["name"] = newName - } - if defaultBranchOnly { - params["default_branch_only"] = true - } - - body := &bytes.Buffer{} - enc := json.NewEncoder(body) - if err := enc.Encode(params); err != nil { - return nil, err - } - - req, err := client.NewRequest(ctx, http.MethodPost, path.String(), body) - if err != nil { - return nil, err - } - - result := repositoryV3{} - if _, err := client.Do(req, &result); err != nil { - return nil, err - } - - newRepo := &api.Repository{ - ID: result.NodeID, - Name: result.Name, - CreatedAt: result.CreatedAt, - Owner: api.RepositoryOwner{ - Login: result.Owner.Login, - }, - ViewerPermission: "WRITE", - } - newRepo = api.InitRepoHostname(newRepo, repo.RepoHost()) - - // The GitHub API will happily return a HTTP 200 when attempting to fork own repo even though no forking - // actually took place. Ensure that we raise an error instead. - if ghrepo.IsSame(repo, newRepo) { - return newRepo, fmt.Errorf("%s cannot be forked. A single user account cannot own both a parent and fork.", ghrepo.FullName(repo)) - } - - return newRepo, nil -} - -// RenameRepo renames the repository on GitHub and returns the renamed repository. -func RenameRepo(ctx context.Context, client *githubrest.Client, repo ghrepo.Interface, newRepoName string) (*api.Repository, error) { - input := map[string]string{"name": newRepoName} - body := &bytes.Buffer{} - enc := json.NewEncoder(body) - if err := enc.Encode(input); err != nil { - return nil, err - } - - path, err := safeurl.JoinPath("repos", repo.RepoOwner(), repo.RepoName()) - if err != nil { - return nil, err - } - - req, err := client.NewRequest(ctx, http.MethodPatch, path.String(), body) - if err != nil { - return nil, err - } - - result := repositoryV3{} - if _, err := client.Do(req, &result); err != nil { - return nil, err - } - - renamed := &api.Repository{ - ID: result.NodeID, - Name: result.Name, - CreatedAt: result.CreatedAt, - Owner: api.RepositoryOwner{ - Login: result.Owner.Login, - }, - ViewerPermission: "WRITE", - } - return api.InitRepoHostname(renamed, repo.RepoHost()), nil -} - -// CreateRepoTransformToV4 issues a REST repository create/edit request and -// transforms the API v3 response into an api.Repository. -func CreateRepoTransformToV4(ctx context.Context, client *githubrest.Client, hostname string, method string, path safeurl.SafeURL, body io.Reader) (*api.Repository, error) { - req, err := client.NewRequest(ctx, method, path.String(), body) - if err != nil { - return nil, err - } - - var responsev3 repositoryV3 - if _, err := client.Do(req, &responsev3); err != nil { - return nil, err - } - - repo := &api.Repository{ - Name: responsev3.Name, - CreatedAt: responsev3.CreatedAt, - Owner: api.RepositoryOwner{ - Login: responsev3.Owner.Login, - }, - ID: responsev3.NodeID, - URL: responsev3.HTMLUrl, - IsPrivate: responsev3.Private, - } - return api.InitRepoHostname(repo, hostname), nil -} - -// RepoLicenses fetches available repository licenses. -// It uses API v3 because licenses are not supported by GraphQL. -func RepoLicenses(ctx context.Context, client *githubrest.Client, hostname string) ([]api.License, error) { - path, err := safeurl.JoinPath("licenses") - if err != nil { - return nil, err - } - req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) - if err != nil { - return nil, err - } - var licenses []api.License - if _, err := client.Do(req, &licenses); err != nil { - return nil, err - } - return licenses, nil -} - -// RepoLicense fetches an available repository license. -// It uses API v3 because licenses are not supported by GraphQL. -func RepoLicense(ctx context.Context, client *githubrest.Client, hostname string, licenseName string) (*api.License, error) { - path, err := safeurl.JoinPath("licenses", licenseName) - if err != nil { - return nil, err - } - req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) - if err != nil { - return nil, err - } - var license api.License - if _, err := client.Do(req, &license); err != nil { - return nil, err - } - return &license, nil -} - -// RepoGitIgnoreTemplates fetches available repository gitignore templates. -// It uses API v3 here because gitignore template isn't supported by GraphQL. -func RepoGitIgnoreTemplates(ctx context.Context, client *githubrest.Client, hostname string) ([]string, error) { - path, err := safeurl.JoinPath("gitignore", "templates") - if err != nil { - return nil, err - } - req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) - if err != nil { - return nil, err - } - var gitIgnoreTemplates []string - if _, err := client.Do(req, &gitIgnoreTemplates); err != nil { - return nil, err - } - return gitIgnoreTemplates, nil -} - -// RepoGitIgnoreTemplate fetches an available repository gitignore template. -// It uses API v3 here because gitignore template isn't supported by GraphQL. -func RepoGitIgnoreTemplate(ctx context.Context, client *githubrest.Client, hostname string, gitIgnoreTemplateName string) (*api.GitIgnore, error) { - path, err := safeurl.JoinPath("gitignore", "templates", gitIgnoreTemplateName) - if err != nil { - return nil, err - } - req, err := client.NewRequest(ctx, http.MethodGet, path.String(), nil) - if err != nil { - return nil, err - } - var gitIgnoreTemplate api.GitIgnore - if _, err := client.Do(req, &gitIgnoreTemplate); err != nil { - return nil, err - } - return &gitIgnoreTemplate, nil -} From c86acb0de53251b13111dd3a4e89da7b2f8f6c91 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 17:09:30 +0200 Subject: [PATCH 20/21] Sanitize JSON responses in the client, not the transport api.Client.REST built a go-gh REST client around whatever transport it was handed, and go-gh wrapped that in an ASCII sanitizer. Losing it means terminal escape sequences in API responses reach the user's screen. githubrest.NewHTTPClient inherits the sanitizer from go-gh, so production was covered, but only by accident of how the *http.Client was built. Tests pass a bare httpmock registry and so ran against a weaker stack than production, which is how a gist view test came to assert that an escape sequence was refused rather than neutralized. Wrapping in NewClient makes it a property of the Client. Non-JSON responses are skipped, because the sanitizer rejects invalid UTF-8 and asset and log downloads are binary. Also restore the 30s response cache the extension manager lost, as a default request option on its client. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- internal/githubrest/client.go | 2 +- internal/githubrest/sanitizer.go | 67 +++++++++++++++++++++++++++ internal/githubrest/sanitizer_test.go | 63 +++++++++++++++++++++++++ pkg/cmd/factory/default.go | 11 ++++- pkg/cmd/gist/view/view_test.go | 4 +- 5 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 internal/githubrest/sanitizer.go create mode 100644 internal/githubrest/sanitizer_test.go diff --git a/internal/githubrest/client.go b/internal/githubrest/client.go index 5287443f2f4..7761449d4e2 100644 --- a/internal/githubrest/client.go +++ b/internal/githubrest/client.go @@ -167,7 +167,7 @@ func NewClient(apiBaseURL string, httpClient *http.Client, auth AuthStrategy, op client := &Client{ apiBaseURL: parsed, - http: httpClient, + http: withResponseSanitizer(httpClient), credentialedHosts: map[string]struct{}{ strings.ToLower(parsed.Host): {}, }, diff --git a/internal/githubrest/sanitizer.go b/internal/githubrest/sanitizer.go new file mode 100644 index 00000000000..caab7bf3b7b --- /dev/null +++ b/internal/githubrest/sanitizer.go @@ -0,0 +1,67 @@ +package githubrest + +import ( + "io" + "net/http" + "regexp" + + "github.com/cli/go-gh/v2/pkg/asciisanitizer" + "golang.org/x/text/transform" +) + +// jsonContentTypeRE matches the JSON content types worth sanitizing. Responses +// that are not JSON are left alone, so asset and log downloads pass through +// untouched; the sanitizer rejects invalid UTF-8 and would fail on them. +var jsonContentTypeRE = regexp.MustCompile(`[/+]json($|;)`) // sanitizingTransport neutralizes ASCII control characters in JSON response +// bodies, so that content the API returns cannot be interpreted by the terminal +// it is eventually printed to. \x1B and \x9B are the dangerous ones. +// +// This behaviour used to come from go-gh: api.Client.REST built a go-gh REST +// client around the caller's transport, and go-gh wrapped it in the same +// transform. Applying it here makes it a property of the Client rather than of +// however the caller assembled its *http.Client, so a transport built elsewhere +// cannot silently drop it. That matters for tests in particular, which pass a +// bare httpmock registry and would otherwise exercise a weaker stack than +// production runs. +// +// Wrapping is idempotent, so the copy go-gh installs inside NewHTTPClient does +// no harm: it replaces control characters with printable stand-ins, which a +// second pass then leaves alone. +type sanitizingTransport struct { + rt http.RoundTripper +} + +func (t sanitizingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + resp, err := t.rt.RoundTrip(req) + if err != nil || resp == nil { + return resp, err + } + if resp.Body == nil || !jsonContentTypeRE.MatchString(resp.Header.Get("Content-Type")) { + return resp, nil + } + resp.Body = struct { + io.Reader + io.Closer + }{ + Reader: transform.NewReader(resp.Body, &asciisanitizer.Sanitizer{JSON: true}), + Closer: resp.Body, + } + return resp, nil +} + +// withResponseSanitizer returns a shallow copy of httpClient whose transport +// sanitizes JSON response bodies. The copy avoids mutating the single +// *http.Client the CLI shares across commands. +func withResponseSanitizer(httpClient *http.Client) *http.Client { + copied := &http.Client{} + if httpClient != nil { + c := *httpClient + copied = &c + } + rt := copied.Transport + if rt == nil { + rt = http.DefaultTransport + } + copied.Transport = sanitizingTransport{rt: rt} + return copied +} diff --git a/internal/githubrest/sanitizer_test.go b/internal/githubrest/sanitizer_test.go new file mode 100644 index 00000000000..fb4b037ea47 --- /dev/null +++ b/internal/githubrest/sanitizer_test.go @@ -0,0 +1,63 @@ +package githubrest_test + +import ( + "context" + "io" + "net/http" + "testing" + + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/require" +) + +// The sanitizer has to live on the Client rather than on the *http.Client it is +// given, because callers assemble transports in several places and a missing +// wrapper would send terminal escape sequences straight to a user's screen. +func TestClientSanitizesControlCharactersInJSONResponses(t *testing.T) { + t.Run("JSON bodies are sanitized", func(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "gists/1234"), + httpmock.JSONResponse(map[string]string{"description": "danger\x1b[31m"}), + ) + + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "gists/1234", nil) + require.NoError(t, err) + + var got struct { + Description string `json:"description"` + } + _, err = client.Do(req, &got) + require.NoError(t, err) + require.Equal(t, "danger^[[31m", got.Description) + }) + + t.Run("non-JSON bodies are left alone", func(t *testing.T) { + reg := &httpmock.Registry{} + reg.Register( + httpmock.REST("GET", "download"), + // Invalid UTF-8, which the sanitizer would reject outright. Asset and + // log downloads look like this. + func(*http.Request) (*http.Response, error) { + return httpmock.StringResponse("\xff\xfe\x00binary")(nil) + }, + ) + + client, err := httpmock.RESTClientFunc(reg)("github.com") + require.NoError(t, err) + + req, err := client.NewRequest(context.Background(), http.MethodGet, "download", nil) + require.NoError(t, err) + + resp, err := client.Send(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "\xff\xfe\x00binary", string(body)) + }) +} diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 963f03a3a34..cb188860a18 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -358,7 +358,16 @@ func branchFunc(f *cmdutil.Factory) func() (string, error) { func extensionManager(f *cmdutil.Factory) *extension.Manager { em := extension.NewManager(f.IOStreams, f.GitClient) - em.SetGitHubREST(f.GitHubREST) + // The extension manager re-reads the same release and manifest endpoints + // several times within a single command, so its requests are cached briefly. + // This matched api.NewCachedHTTPClient, which still wraps the GraphQL client + // below. + em.SetGitHubREST(func(host string, opts ...githubrest.ClientOption) (*githubrest.Client, error) { + opts = append([]githubrest.ClientOption{ + githubrest.WithDefaultRequestOptions(githubrest.WithCacheTTL(30 * time.Second)), + }, opts...) + return f.GitHubREST(host, opts...) + }) cfg, err := f.Config() if err != nil { diff --git a/pkg/cmd/gist/view/view_test.go b/pkg/cmd/gist/view/view_test.go index 2d993f537ea..e300c5e26e2 100644 --- a/pkg/cmd/gist/view/view_test.go +++ b/pkg/cmd/gist/view/view_test.go @@ -226,7 +226,7 @@ func Test_viewRun(t *testing.T) { wantOut: "danger\x1b[31m\n", }, { - name: "piped inline file with escape sequences is refused", + name: "piped inline file escapes are already neutralized by the JSON transport", isTTY: false, opts: &ViewOptions{ Selector: "1234", @@ -240,7 +240,7 @@ func Test_viewRun(t *testing.T) { }, }, }, - wantErr: "gist file contains terminal escape sequences; pass --allow-escape-sequences to view it anyway", + wantOut: "danger^[[31m\n", }, { name: "one file, no ID supplied", From ebc544ff5253a9a71e897401c91068a5ec0b35cc Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 5 Aug 2026 17:16:35 +0200 Subject: [PATCH 21/21] Use context.Background in the update check test Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f7975050-5872-4122-b93e-6476fd265166 --- internal/update/update_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 62c0156bb0a..796fd90644e 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -88,7 +88,7 @@ func TestCheckForUpdate(t *testing.T) { }`, s.LatestVersion, s.LatestURL)), ) - rel, err := CheckForUpdate(context.TODO(), client, tempFilePath(), "OWNER/REPO", s.CurrentVersion) + rel, err := CheckForUpdate(context.Background(), client, tempFilePath(), "OWNER/REPO", s.CurrentVersion) if err != nil { t.Fatal(err) }