Integrate go-gh API package (#5614)
This commit is contained in:
parent
6e3689d58b
commit
074ed50b8a
37 changed files with 542 additions and 1006 deletions
178
api/cache.go
178
api/cache.go
|
|
@ -1,178 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewCachedClient(httpClient *http.Client, cacheTTL time.Duration) *http.Client {
|
||||
cacheDir := filepath.Join(os.TempDir(), "gh-cli-cache")
|
||||
return &http.Client{
|
||||
Transport: CacheResponse(cacheTTL, cacheDir)(httpClient.Transport),
|
||||
}
|
||||
}
|
||||
|
||||
func isCacheableRequest(req *http.Request) bool {
|
||||
if strings.EqualFold(req.Method, "GET") || strings.EqualFold(req.Method, "HEAD") {
|
||||
return true
|
||||
}
|
||||
|
||||
if strings.EqualFold(req.Method, "POST") && (req.URL.Path == "/graphql" || req.URL.Path == "/api/graphql") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func isCacheableResponse(res *http.Response) bool {
|
||||
return res.StatusCode < 500 && res.StatusCode != 403
|
||||
}
|
||||
|
||||
// CacheResponse produces a RoundTripper that caches HTTP responses to disk for a specified amount of time
|
||||
func CacheResponse(ttl time.Duration, dir string) ClientOption {
|
||||
fs := fileStorage{
|
||||
dir: dir,
|
||||
ttl: ttl,
|
||||
mu: &sync.RWMutex{},
|
||||
}
|
||||
|
||||
return func(tr http.RoundTripper) http.RoundTripper {
|
||||
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
|
||||
if !isCacheableRequest(req) {
|
||||
return tr.RoundTrip(req)
|
||||
}
|
||||
|
||||
key, keyErr := cacheKey(req)
|
||||
if keyErr == nil {
|
||||
if res, err := fs.read(key); err == nil {
|
||||
res.Request = req
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
res, err := tr.RoundTrip(req)
|
||||
if err == nil && keyErr == nil && isCacheableResponse(res) {
|
||||
_ = fs.store(key, res)
|
||||
}
|
||||
return res, err
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
func copyStream(r io.ReadCloser) (io.ReadCloser, io.ReadCloser) {
|
||||
b := &bytes.Buffer{}
|
||||
nr := io.TeeReader(r, b)
|
||||
return io.NopCloser(b), &readCloser{
|
||||
Reader: nr,
|
||||
Closer: r,
|
||||
}
|
||||
}
|
||||
|
||||
type readCloser struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}
|
||||
|
||||
func cacheKey(req *http.Request) (string, error) {
|
||||
h := sha256.New()
|
||||
fmt.Fprintf(h, "%s:", req.Method)
|
||||
fmt.Fprintf(h, "%s:", req.URL.String())
|
||||
fmt.Fprintf(h, "%s:", req.Header.Get("Accept"))
|
||||
fmt.Fprintf(h, "%s:", req.Header.Get("Authorization"))
|
||||
|
||||
if req.Body != nil {
|
||||
var bodyCopy io.ReadCloser
|
||||
req.Body, bodyCopy = copyStream(req.Body)
|
||||
defer bodyCopy.Close()
|
||||
if _, err := io.Copy(h, bodyCopy); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
digest := h.Sum(nil)
|
||||
return fmt.Sprintf("%x", digest), nil
|
||||
}
|
||||
|
||||
type fileStorage struct {
|
||||
dir string
|
||||
ttl time.Duration
|
||||
mu *sync.RWMutex
|
||||
}
|
||||
|
||||
func (fs *fileStorage) filePath(key string) string {
|
||||
if len(key) >= 6 {
|
||||
return filepath.Join(fs.dir, key[0:2], key[2:4], key[4:])
|
||||
}
|
||||
return filepath.Join(fs.dir, key)
|
||||
}
|
||||
|
||||
func (fs *fileStorage) read(key string) (*http.Response, error) {
|
||||
cacheFile := fs.filePath(key)
|
||||
|
||||
fs.mu.RLock()
|
||||
defer fs.mu.RUnlock()
|
||||
|
||||
f, err := os.Open(cacheFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
age := time.Since(stat.ModTime())
|
||||
if age > fs.ttl {
|
||||
return nil, errors.New("cache expired")
|
||||
}
|
||||
|
||||
body := &bytes.Buffer{}
|
||||
_, err = io.Copy(body, f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := http.ReadResponse(bufio.NewReader(body), nil)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (fs *fileStorage) store(key string, res *http.Response) error {
|
||||
cacheFile := fs.filePath(key)
|
||||
|
||||
fs.mu.Lock()
|
||||
defer fs.mu.Unlock()
|
||||
|
||||
err := os.MkdirAll(filepath.Dir(cacheFile), 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(cacheFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var origBody io.ReadCloser
|
||||
if res.Body != nil {
|
||||
origBody, res.Body = copyStream(res.Body)
|
||||
defer res.Body.Close()
|
||||
}
|
||||
err = res.Write(f)
|
||||
if origBody != nil {
|
||||
res.Body = origBody
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_CacheResponse(t *testing.T) {
|
||||
counter := 0
|
||||
fakeHTTP := funcTripper{
|
||||
roundTrip: func(req *http.Request) (*http.Response, error) {
|
||||
counter += 1
|
||||
body := fmt.Sprintf("%d: %s %s", counter, req.Method, req.URL.String())
|
||||
status := 200
|
||||
if req.URL.Path == "/error" {
|
||||
status = 500
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Body: io.NopCloser(bytes.NewBufferString(body)),
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
cacheDir := filepath.Join(t.TempDir(), "gh-cli-cache")
|
||||
httpClient := NewHTTPClient(ReplaceTripper(fakeHTTP), CacheResponse(time.Minute, cacheDir))
|
||||
|
||||
do := func(method, url string, body io.Reader) (string, error) {
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
res, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
resBody, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
err = fmt.Errorf("ReadAll: %w", err)
|
||||
}
|
||||
return string(resBody), err
|
||||
}
|
||||
|
||||
var res string
|
||||
var err error
|
||||
|
||||
res, err = do("GET", "http://example.com/path", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1: GET http://example.com/path", res)
|
||||
res, err = do("GET", "http://example.com/path", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "1: GET http://example.com/path", res)
|
||||
|
||||
res, err = do("GET", "http://example.com/path2", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "2: GET http://example.com/path2", res)
|
||||
|
||||
res, err = do("POST", "http://example.com/path2", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "3: POST http://example.com/path2", res)
|
||||
|
||||
res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "4: POST http://example.com/graphql", res)
|
||||
res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "4: POST http://example.com/graphql", res)
|
||||
|
||||
res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello2`))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "5: POST http://example.com/graphql", res)
|
||||
|
||||
res, err = do("GET", "http://example.com/error", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "6: GET http://example.com/error", res)
|
||||
res, err = do("GET", "http://example.com/error", nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "7: GET http://example.com/error", res)
|
||||
}
|
||||
533
api/client.go
533
api/client.go
|
|
@ -1,127 +1,26 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
graphql "github.com/cli/shurcooL-graphql"
|
||||
"github.com/henvic/httpretty"
|
||||
"github.com/cli/go-gh"
|
||||
ghAPI "github.com/cli/go-gh/pkg/api"
|
||||
)
|
||||
|
||||
// ClientOption represents an argument to NewClient
|
||||
type ClientOption = func(http.RoundTripper) http.RoundTripper
|
||||
var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
|
||||
|
||||
// NewHTTPClient initializes an http.Client
|
||||
func NewHTTPClient(opts ...ClientOption) *http.Client {
|
||||
tr := http.DefaultTransport
|
||||
for _, opt := range opts {
|
||||
tr = opt(tr)
|
||||
}
|
||||
return &http.Client{Transport: tr}
|
||||
}
|
||||
|
||||
// NewClient initializes a Client
|
||||
func NewClient(opts ...ClientOption) *Client {
|
||||
client := &Client{http: NewHTTPClient(opts...)}
|
||||
return client
|
||||
}
|
||||
|
||||
// NewClientFromHTTP takes in an http.Client instance
|
||||
func NewClientFromHTTP(httpClient *http.Client) *Client {
|
||||
client := &Client{http: httpClient}
|
||||
return client
|
||||
}
|
||||
|
||||
// AddHeader turns a RoundTripper into one that adds a request header
|
||||
func AddHeader(name, value string) ClientOption {
|
||||
return func(tr http.RoundTripper) http.RoundTripper {
|
||||
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get(name) == "" {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
return tr.RoundTrip(req)
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// AddHeaderFunc is an AddHeader that gets the string value from a function
|
||||
func AddHeaderFunc(name string, getValue func(*http.Request) (string, error)) ClientOption {
|
||||
return func(tr http.RoundTripper) http.RoundTripper {
|
||||
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
|
||||
if req.Header.Get(name) != "" {
|
||||
return tr.RoundTrip(req)
|
||||
}
|
||||
value, err := getValue(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value != "" {
|
||||
req.Header.Add(name, value)
|
||||
}
|
||||
return tr.RoundTrip(req)
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// VerboseLog enables request/response logging within a RoundTripper
|
||||
func VerboseLog(out io.Writer, logTraffic bool, colorize bool) ClientOption {
|
||||
logger := &httpretty.Logger{
|
||||
Time: true,
|
||||
TLS: false,
|
||||
Colors: colorize,
|
||||
RequestHeader: logTraffic,
|
||||
RequestBody: logTraffic,
|
||||
ResponseHeader: logTraffic,
|
||||
ResponseBody: logTraffic,
|
||||
Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}},
|
||||
MaxResponseBody: 10000,
|
||||
}
|
||||
logger.SetOutput(out)
|
||||
logger.SetBodyFilter(func(h http.Header) (skip bool, err error) {
|
||||
return !inspectableMIMEType(h.Get("Content-Type")), nil
|
||||
})
|
||||
return logger.RoundTripper
|
||||
}
|
||||
|
||||
// ReplaceTripper substitutes the underlying RoundTripper with a custom one
|
||||
func ReplaceTripper(tr http.RoundTripper) ClientOption {
|
||||
return func(http.RoundTripper) http.RoundTripper {
|
||||
return tr
|
||||
}
|
||||
}
|
||||
|
||||
// ExtractHeader extracts a named header from any response received by this client and, if non-blank, saves
|
||||
// it to dest.
|
||||
func ExtractHeader(name string, dest *string) ClientOption {
|
||||
return func(tr http.RoundTripper) http.RoundTripper {
|
||||
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
|
||||
res, err := tr.RoundTrip(req)
|
||||
if err == nil {
|
||||
if value := res.Header.Get(name); value != "" {
|
||||
*dest = value
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
type funcTripper struct {
|
||||
roundTrip func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (tr funcTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return tr.roundTrip(req)
|
||||
}
|
||||
|
||||
// Client facilitates making HTTP requests to the GitHub API
|
||||
type Client struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
|
@ -130,103 +29,168 @@ func (c *Client) HTTP() *http.Client {
|
|||
return c.http
|
||||
}
|
||||
|
||||
type graphQLResponse struct {
|
||||
Data interface{}
|
||||
Errors []GraphQLError
|
||||
}
|
||||
|
||||
// GraphQLError is a single error returned in a GraphQL response
|
||||
type GraphQLError struct {
|
||||
Type string
|
||||
Message string
|
||||
Path []interface{} // mixed strings and numbers
|
||||
ghAPI.GQLError
|
||||
}
|
||||
|
||||
func (ge GraphQLError) PathString() string {
|
||||
var res strings.Builder
|
||||
for i, v := range ge.Path {
|
||||
if i > 0 {
|
||||
res.WriteRune('.')
|
||||
}
|
||||
fmt.Fprintf(&res, "%v", v)
|
||||
}
|
||||
return res.String()
|
||||
}
|
||||
|
||||
// GraphQLErrorResponse contains errors returned in a GraphQL response
|
||||
type GraphQLErrorResponse struct {
|
||||
Errors []GraphQLError
|
||||
}
|
||||
|
||||
func (gr GraphQLErrorResponse) Error() string {
|
||||
errorMessages := make([]string, 0, len(gr.Errors))
|
||||
for _, e := range gr.Errors {
|
||||
msg := e.Message
|
||||
if p := e.PathString(); p != "" {
|
||||
msg = fmt.Sprintf("%s (%s)", msg, p)
|
||||
}
|
||||
errorMessages = append(errorMessages, msg)
|
||||
}
|
||||
return fmt.Sprintf("GraphQL: %s", strings.Join(errorMessages, ", "))
|
||||
}
|
||||
|
||||
// Match checks if this error is only about a specific type on a specific path. If the path argument ends
|
||||
// with a ".", it will match all its subpaths as well.
|
||||
func (gr GraphQLErrorResponse) Match(expectType, expectPath string) bool {
|
||||
for _, e := range gr.Errors {
|
||||
if e.Type != expectType || !matchPath(e.PathString(), expectPath) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func matchPath(p, expect string) bool {
|
||||
if strings.HasSuffix(expect, ".") {
|
||||
return strings.HasPrefix(p, expect) || p == strings.TrimSuffix(expect, ".")
|
||||
}
|
||||
return p == expect
|
||||
}
|
||||
|
||||
// HTTPError is an error returned by a failed API call
|
||||
type HTTPError struct {
|
||||
StatusCode int
|
||||
RequestURL *url.URL
|
||||
Message string
|
||||
Errors []HTTPErrorItem
|
||||
|
||||
ghAPI.HTTPError
|
||||
scopesSuggestion string
|
||||
}
|
||||
|
||||
type HTTPErrorItem struct {
|
||||
Message string
|
||||
Resource string
|
||||
Field string
|
||||
Code string
|
||||
}
|
||||
|
||||
func (err HTTPError) Error() string {
|
||||
if msgs := strings.SplitN(err.Message, "\n", 2); len(msgs) > 1 {
|
||||
return fmt.Sprintf("HTTP %d: %s (%s)\n%s", err.StatusCode, msgs[0], err.RequestURL, msgs[1])
|
||||
} else if err.Message != "" {
|
||||
return fmt.Sprintf("HTTP %d: %s (%s)", err.StatusCode, err.Message, err.RequestURL)
|
||||
}
|
||||
return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL)
|
||||
}
|
||||
|
||||
func (err HTTPError) ScopesSuggestion() string {
|
||||
return err.scopesSuggestion
|
||||
}
|
||||
|
||||
// GraphQL performs a GraphQL request and parses the response. If there are errors in the response,
|
||||
// GraphQLError will be returned, but the data will also be parsed into the receiver.
|
||||
func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error {
|
||||
// AuthToken is being handled by Transport, so let go-gh know that it does not need to resolve it.
|
||||
opts := ghAPI.ClientOptions{Host: hostname, AuthToken: "none", Transport: c.http.Transport}
|
||||
opts.Headers = map[string]string{"GraphQL-Features": "merge_queue"}
|
||||
gqlClient, err := gh.GQLClient(&opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return handleResponse(gqlClient.Do(query, variables, data))
|
||||
}
|
||||
|
||||
// GraphQL performs a GraphQL mutation and parses the response. If there are errors in the response,
|
||||
// GraphQLError will be returned, but the data will also be parsed into the receiver.
|
||||
func (c Client) Mutate(hostname, name string, mutation interface{}, variables map[string]interface{}) error {
|
||||
// AuthToken is being handled by Transport, so let go-gh know that it does not need to resolve it.
|
||||
opts := ghAPI.ClientOptions{Host: hostname, AuthToken: "none", Transport: c.http.Transport}
|
||||
gqlClient, err := gh.GQLClient(&opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return handleResponse(gqlClient.Mutate(name, mutation, variables))
|
||||
}
|
||||
|
||||
// GraphQL performs a GraphQL query and parses the response. If there are errors in the response,
|
||||
// GraphQLError will be returned, but the data will also be parsed into the receiver.
|
||||
func (c Client) Query(hostname, name string, query interface{}, variables map[string]interface{}) error {
|
||||
// AuthToken is being handled by Transport, so let go-gh know that it does not need to resolve it.
|
||||
opts := ghAPI.ClientOptions{Host: hostname, AuthToken: "none", Transport: c.http.Transport}
|
||||
gqlClient, err := gh.GQLClient(&opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return handleResponse(gqlClient.Query(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 {
|
||||
// AuthToken is being handled by Transport, so let go-gh know that it does not need to resolve it.
|
||||
opts := ghAPI.ClientOptions{Host: hostname, AuthToken: "none", Transport: c.http.Transport}
|
||||
restClient, err := gh.RESTClient(&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) {
|
||||
// AuthToken is being handled by Transport, so let go-gh know that it does not need to resolve it.
|
||||
opts := ghAPI.ClientOptions{Host: hostname, AuthToken: "none", Transport: c.http.Transport}
|
||||
restClient, err := gh.RESTClient(&opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
resp, err := restClient.Request(method, p, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
success := resp.StatusCode >= 200 && resp.StatusCode < 300
|
||||
if !success {
|
||||
return "", HandleHTTPError(resp)
|
||||
}
|
||||
|
||||
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 HTTPError.
|
||||
func HandleHTTPError(resp *http.Response) error {
|
||||
return handleResponse(ghAPI.HandleHTTPError(resp))
|
||||
}
|
||||
|
||||
// handleResponse takes a ghAPI.HTTPError or ghAPI.GQLError and converts it into an
|
||||
// HTTPError or GraphQLError respectively.
|
||||
func handleResponse(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
var gqlErr ghAPI.GQLError
|
||||
if errors.As(err, &gqlErr) {
|
||||
return GraphQLError{
|
||||
GQLError: gqlErr,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
if resp.StatusCode < 400 || resp.StatusCode > 499 || resp.StatusCode == 422 {
|
||||
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) *http.Response {
|
||||
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))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func generateScopesSuggestion(statusCode int, endpointNeedsScopes, tokenHasScopes, hostname string) string {
|
||||
if statusCode < 400 || statusCode > 499 || statusCode == 422 {
|
||||
return ""
|
||||
}
|
||||
|
||||
endpointNeedsScopes := resp.Header.Get("X-Accepted-Oauth-Scopes")
|
||||
tokenHasScopes := resp.Header.Get("X-Oauth-Scopes")
|
||||
if tokenHasScopes == "" {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -267,206 +231,9 @@ func ScopesSuggestion(resp *http.Response) string {
|
|||
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,
|
||||
ghinstance.NormalizeHostname(resp.Request.URL.Hostname()),
|
||||
ghinstance.NormalizeHostname(hostname),
|
||||
)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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) *http.Response {
|
||||
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))
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// GraphQL performs a GraphQL request and parses the response. If there are errors in the response,
|
||||
// *GraphQLErrorResponse will be returned, but the data will also be parsed into the receiver.
|
||||
func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error {
|
||||
reqBody, err := json.Marshal(map[string]interface{}{"query": query, "variables": variables})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", ghinstance.GraphQLEndpoint(hostname), bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
req.Header.Set("GraphQL-Features", "merge_queue")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return handleResponse(resp, data)
|
||||
}
|
||||
|
||||
func graphQLClient(h *http.Client, hostname string) *graphql.Client {
|
||||
return graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), h)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
_, err := c.RESTWithNext(hostname, method, p, body, data)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c Client) RESTWithNext(hostname string, method string, p string, body io.Reader, data interface{}) (string, error) {
|
||||
req, err := http.NewRequest(method, restURL(hostname, p), body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
success := resp.StatusCode >= 200 && resp.StatusCode < 300
|
||||
if !success {
|
||||
return "", HandleHTTPError(resp)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
|
||||
|
||||
func restURL(hostname string, pathOrURL string) string {
|
||||
if strings.HasPrefix(pathOrURL, "https://") || strings.HasPrefix(pathOrURL, "http://") {
|
||||
return pathOrURL
|
||||
}
|
||||
return ghinstance.RESTPrefix(hostname) + pathOrURL
|
||||
}
|
||||
|
||||
func handleResponse(resp *http.Response, data interface{}) error {
|
||||
success := resp.StatusCode >= 200 && resp.StatusCode < 300
|
||||
|
||||
if !success {
|
||||
return HandleHTTPError(resp)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gr := &graphQLResponse{Data: data}
|
||||
err = json.Unmarshal(body, &gr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(gr.Errors) > 0 {
|
||||
return &GraphQLErrorResponse{Errors: gr.Errors}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func HandleHTTPError(resp *http.Response) error {
|
||||
httpError := HTTPError{
|
||||
StatusCode: resp.StatusCode,
|
||||
RequestURL: resp.Request.URL,
|
||||
scopesSuggestion: ScopesSuggestion(resp),
|
||||
}
|
||||
|
||||
if !jsonTypeRE.MatchString(resp.Header.Get("Content-Type")) {
|
||||
httpError.Message = resp.Status
|
||||
return httpError
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
httpError.Message = err.Error()
|
||||
return httpError
|
||||
}
|
||||
|
||||
var parsedBody struct {
|
||||
Message string `json:"message"`
|
||||
Errors []json.RawMessage
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsedBody); err != nil {
|
||||
return httpError
|
||||
}
|
||||
|
||||
var messages []string
|
||||
if parsedBody.Message != "" {
|
||||
messages = append(messages, parsedBody.Message)
|
||||
}
|
||||
for _, raw := range parsedBody.Errors {
|
||||
switch raw[0] {
|
||||
case '"':
|
||||
var errString string
|
||||
_ = json.Unmarshal(raw, &errString)
|
||||
messages = append(messages, errString)
|
||||
httpError.Errors = append(httpError.Errors, HTTPErrorItem{Message: errString})
|
||||
case '{':
|
||||
var errInfo HTTPErrorItem
|
||||
_ = json.Unmarshal(raw, &errInfo)
|
||||
msg := errInfo.Message
|
||||
if errInfo.Code != "" && errInfo.Code != "custom" {
|
||||
msg = fmt.Sprintf("%s.%s %s", errInfo.Resource, errInfo.Field, errorCodeToMessage(errInfo.Code))
|
||||
}
|
||||
if msg != "" {
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
httpError.Errors = append(httpError.Errors, errInfo)
|
||||
}
|
||||
}
|
||||
httpError.Message = strings.Join(messages, "\n")
|
||||
|
||||
return httpError
|
||||
}
|
||||
|
||||
func errorCodeToMessage(code string) string {
|
||||
// https://docs.github.com/en/rest/overview/resources-in-the-rest-api#client-errors
|
||||
switch code {
|
||||
case "missing", "missing_field":
|
||||
return "is missing"
|
||||
case "invalid", "unprocessable":
|
||||
return "is invalid"
|
||||
case "already_exists":
|
||||
return "already exists"
|
||||
default:
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
|
||||
|
||||
func inspectableMIMEType(t string) bool {
|
||||
return strings.HasPrefix(t, "text/") || jsonTypeRE.MatchString(t)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,12 +11,15 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func newTestClient(reg *httpmock.Registry) *Client {
|
||||
client := &http.Client{}
|
||||
httpmock.ReplaceTripper(client, reg)
|
||||
return NewClientFromHTTP(client)
|
||||
}
|
||||
|
||||
func TestGraphQL(t *testing.T) {
|
||||
http := &httpmock.Registry{}
|
||||
client := NewClient(
|
||||
ReplaceTripper(http),
|
||||
AddHeader("Authorization", "token OTOKEN"),
|
||||
)
|
||||
client := newTestClient(http)
|
||||
|
||||
vars := map[string]interface{}{"name": "Mona"}
|
||||
response := struct {
|
||||
|
|
@ -37,16 +40,15 @@ func TestGraphQL(t *testing.T) {
|
|||
req := http.Requests[0]
|
||||
reqBody, _ := io.ReadAll(req.Body)
|
||||
assert.Equal(t, `{"query":"QUERY","variables":{"name":"Mona"}}`, string(reqBody))
|
||||
assert.Equal(t, "token OTOKEN", req.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestGraphQLError(t *testing.T) {
|
||||
http := &httpmock.Registry{}
|
||||
client := NewClient(ReplaceTripper(http))
|
||||
reg := &httpmock.Registry{}
|
||||
client := newTestClient(reg)
|
||||
|
||||
response := struct{}{}
|
||||
|
||||
http.Register(
|
||||
reg.Register(
|
||||
httpmock.GraphQL(""),
|
||||
httpmock.StringResponse(`
|
||||
{ "errors": [
|
||||
|
|
@ -73,10 +75,7 @@ func TestGraphQLError(t *testing.T) {
|
|||
|
||||
func TestRESTGetDelete(t *testing.T) {
|
||||
http := &httpmock.Registry{}
|
||||
|
||||
client := NewClient(
|
||||
ReplaceTripper(http),
|
||||
)
|
||||
client := newTestClient(http)
|
||||
|
||||
http.Register(
|
||||
httpmock.REST("DELETE", "applications/CLIENTID/grant"),
|
||||
|
|
@ -90,7 +89,7 @@ func TestRESTGetDelete(t *testing.T) {
|
|||
|
||||
func TestRESTWithFullURL(t *testing.T) {
|
||||
http := &httpmock.Registry{}
|
||||
client := NewClient(ReplaceTripper(http))
|
||||
client := newTestClient(http)
|
||||
|
||||
http.Register(
|
||||
httpmock.REST("GET", "api/v3/user/repos"),
|
||||
|
|
@ -110,7 +109,7 @@ func TestRESTWithFullURL(t *testing.T) {
|
|||
|
||||
func TestRESTError(t *testing.T) {
|
||||
fakehttp := &httpmock.Registry{}
|
||||
client := NewClient(ReplaceTripper(fakehttp))
|
||||
client := newTestClient(fakehttp)
|
||||
|
||||
fakehttp.Register(httpmock.MatchAny, func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
|
|
@ -134,7 +133,6 @@ func TestRESTError(t *testing.T) {
|
|||
}
|
||||
if httpErr.Error() != "HTTP 422: OH NO (https://api.github.com/repos/branch)" {
|
||||
t.Errorf("got %q", httpErr.Error())
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
114
api/http_client.go
Normal file
114
api/http_client.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
"github.com/cli/cli/v2/utils"
|
||||
"github.com/cli/go-gh"
|
||||
ghAPI "github.com/cli/go-gh/pkg/api"
|
||||
)
|
||||
|
||||
type configGetter interface {
|
||||
Get(string, string) (string, error)
|
||||
}
|
||||
|
||||
type HTTPClientOptions struct {
|
||||
AppVersion string
|
||||
CacheTTL time.Duration
|
||||
Config configGetter
|
||||
EnableCache bool
|
||||
Log io.Writer
|
||||
SkipAcceptHeaders bool
|
||||
}
|
||||
|
||||
func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
|
||||
// Provide invalid host, and token values so gh.HTTPClient will not automatically resolve them.
|
||||
// The real host and token are inserted at request time.
|
||||
clientOpts := ghAPI.ClientOptions{Host: "none", AuthToken: "none"}
|
||||
|
||||
if debugEnabled, _ := utils.IsDebugEnabled(); debugEnabled {
|
||||
clientOpts.Log = opts.Log
|
||||
}
|
||||
|
||||
headers := map[string]string{
|
||||
"User-Agent": fmt.Sprintf("GitHub CLI %s", opts.AppVersion),
|
||||
}
|
||||
if opts.SkipAcceptHeaders {
|
||||
headers["Accept"] = ""
|
||||
}
|
||||
clientOpts.Headers = headers
|
||||
|
||||
if opts.EnableCache {
|
||||
clientOpts.EnableCache = opts.EnableCache
|
||||
clientOpts.CacheTTL = opts.CacheTTL
|
||||
}
|
||||
|
||||
client, err := gh.HTTPClient(&clientOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.Transport = AddAuthTokenHeader(client.Transport, opts.Config)
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func NewCachedHTTPClient(httpClient *http.Client, ttl time.Duration) *http.Client {
|
||||
httpClient.Transport = AddCacheTTLHeader(httpClient.Transport, ttl)
|
||||
return httpClient
|
||||
}
|
||||
|
||||
// AddCacheTTLHeader adds an header to the request telling the cache that the request
|
||||
// should be cached for a specified amount of time.
|
||||
func AddCacheTTLHeader(rt http.RoundTripper, ttl time.Duration) http.RoundTripper {
|
||||
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
|
||||
req.Header.Set("X-GH-CACHE-TTL", ttl.String())
|
||||
return rt.RoundTrip(req)
|
||||
}}
|
||||
}
|
||||
|
||||
// AddAuthToken adds an authentication token header for the host specified by the request.
|
||||
func AddAuthTokenHeader(rt http.RoundTripper, cfg configGetter) http.RoundTripper {
|
||||
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
|
||||
hostname := ghinstance.NormalizeHostname(getHost(req))
|
||||
if token, err := cfg.Get(hostname, "oauth_token"); err == nil && token != "" {
|
||||
req.Header.Set("Authorization", fmt.Sprintf("token %s", token))
|
||||
}
|
||||
return rt.RoundTrip(req)
|
||||
}}
|
||||
}
|
||||
|
||||
// ExtractHeader extracts a named header from any response received by this client and,
|
||||
// if non-blank, saves it to dest.
|
||||
func ExtractHeader(name string, dest *string) func(http.RoundTripper) http.RoundTripper {
|
||||
return func(tr http.RoundTripper) http.RoundTripper {
|
||||
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
|
||||
res, err := tr.RoundTrip(req)
|
||||
if err == nil {
|
||||
if value := res.Header.Get(name); value != "" {
|
||||
*dest = value
|
||||
}
|
||||
}
|
||||
return res, err
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
type funcTripper struct {
|
||||
roundTrip func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (tr funcTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return tr.roundTrip(req)
|
||||
}
|
||||
|
||||
func getHost(r *http.Request) string {
|
||||
if r.Host != "" {
|
||||
return r.Host
|
||||
}
|
||||
return r.URL.Hostname()
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package factory
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
|
@ -27,10 +27,8 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
setGhDebug bool
|
||||
envGhDebug string
|
||||
host string
|
||||
sso string
|
||||
wantHeader map[string]string
|
||||
wantStderr string
|
||||
wantSSO string
|
||||
}{
|
||||
{
|
||||
name: "github.com with Accept header",
|
||||
|
|
@ -99,6 +97,8 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
> Host: github.com
|
||||
> Accept: application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview
|
||||
> Authorization: token ████████████████████
|
||||
> Content-Type: application/json; charset=utf-8
|
||||
> Time-Zone: <timezone>
|
||||
> User-Agent: GitHub CLI v1.2.3
|
||||
|
||||
< HTTP/1.1 204 No Content
|
||||
|
|
@ -129,6 +129,8 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
> Host: github.com
|
||||
> Accept: application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview
|
||||
> Authorization: token ████████████████████
|
||||
> Content-Type: application/json; charset=utf-8
|
||||
> Time-Zone: <timezone>
|
||||
> User-Agent: GitHub CLI v1.2.3
|
||||
|
||||
< HTTP/1.1 204 No Content
|
||||
|
|
@ -148,29 +150,15 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
wantHeader: map[string]string{
|
||||
"authorization": "token GHETOKEN",
|
||||
"user-agent": "GitHub CLI v1.2.3",
|
||||
"accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview, application/vnd.github.antiope-preview, application/vnd.github.shadow-cat-preview",
|
||||
"accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview",
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "SSO challenge in response header",
|
||||
args: args{
|
||||
config: tinyConfig{},
|
||||
appVersion: "v1.2.3",
|
||||
},
|
||||
host: "github.com",
|
||||
sso: "required; url=https://github.com/login/sso?return_to=xyz¶m=123abc; another",
|
||||
wantStderr: "",
|
||||
wantSSO: "https://github.com/login/sso?return_to=xyz¶m=123abc",
|
||||
},
|
||||
}
|
||||
|
||||
var gotReq *http.Request
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotReq = r
|
||||
if sso := r.URL.Query().Get("sso"); sso != "" {
|
||||
w.Header().Set("X-GitHub-SSO", sso)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
|
@ -191,19 +179,20 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
})
|
||||
|
||||
ios, _, _, stderr := iostreams.Test()
|
||||
client, err := NewHTTPClient(ios, tt.args.config, tt.args.appVersion, tt.args.setAccept)
|
||||
client, err := NewHTTPClient(HTTPClientOptions{
|
||||
AppVersion: tt.args.appVersion,
|
||||
Config: tt.args.config,
|
||||
Log: ios.ErrOut,
|
||||
SkipAcceptHeaders: !tt.args.setAccept,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req, err := http.NewRequest("GET", ts.URL, nil)
|
||||
if tt.sso != "" {
|
||||
q := req.URL.Query()
|
||||
q.Set("sso", tt.sso)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
}
|
||||
req.Host = tt.host
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := client.Do(req)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
for name, value := range tt.wantHeader {
|
||||
|
|
@ -212,7 +201,6 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
|
||||
assert.Equal(t, 204, res.StatusCode)
|
||||
assert.Equal(t, tt.wantStderr, normalizeVerboseLog(stderr.String()))
|
||||
assert.Equal(t, tt.wantSSO, SSOURL())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -227,11 +215,13 @@ var requestAtRE = regexp.MustCompile(`(?m)^\* Request at .+`)
|
|||
var dateRE = regexp.MustCompile(`(?m)^< Date: .+`)
|
||||
var hostWithPortRE = regexp.MustCompile(`127\.0\.0\.1:\d+`)
|
||||
var durationRE = regexp.MustCompile(`(?m)^\* Request took .+`)
|
||||
var timezoneRE = regexp.MustCompile(`(?m)^> Time-Zone: .+`)
|
||||
|
||||
func normalizeVerboseLog(t string) string {
|
||||
t = requestAtRE.ReplaceAllString(t, "* Request at <time>")
|
||||
t = hostWithPortRE.ReplaceAllString(t, "<host>:<port>")
|
||||
t = dateRE.ReplaceAllString(t, "< Date: <time>")
|
||||
t = durationRE.ReplaceAllString(t, "* Request took <duration>")
|
||||
t = timezoneRE.ReplaceAllString(t, "> Time-Zone: <timezone>")
|
||||
return t
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
graphql "github.com/cli/shurcooL-graphql"
|
||||
|
|
@ -51,8 +50,7 @@ func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (
|
|||
},
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repoHost)
|
||||
err := gql.MutateNamed(context.Background(), "CommentCreate", &mutation, variables)
|
||||
err := client.Mutate(repoHost, "CommentCreate", &mutation, variables)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/shurcooL/githubv4"
|
||||
)
|
||||
|
|
@ -26,12 +24,10 @@ func OrganizationProjects(client *Client, repo ghrepo.Interface) ([]RepoProject,
|
|||
"endCursor": (*githubv4.String)(nil),
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
|
||||
var projects []RepoProject
|
||||
for {
|
||||
var query responseData
|
||||
err := gql.QueryNamed(context.Background(), "OrganizationProjectList", &query, variables)
|
||||
err := client.Query(repo.RepoHost(), "OrganizationProjectList", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -70,12 +66,10 @@ func OrganizationTeams(client *Client, repo ghrepo.Interface) ([]OrgTeam, error)
|
|||
"endCursor": (*githubv4.String)(nil),
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
|
||||
var teams []OrgTeam
|
||||
for {
|
||||
var query responseData
|
||||
err := gql.QueryNamed(context.Background(), "OrganizationTeamList", &query, variables)
|
||||
err := client.Query(repo.RepoHost(), "OrganizationTeamList", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
|
@ -362,8 +361,7 @@ func UpdatePullRequestReviews(client *Client, repo ghrepo.Interface, params gith
|
|||
} `graphql:"requestReviews(input: $input)"`
|
||||
}
|
||||
variables := map[string]interface{}{"input": params}
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
err := gql.MutateNamed(context.Background(), "PullRequestUpdateRequestReviews", &mutation, variables)
|
||||
err := client.Mutate(repo.RepoHost(), "PullRequestUpdateRequestReviews", &mutation, variables)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -393,8 +391,8 @@ func PullRequestClose(httpClient *http.Client, repo ghrepo.Interface, prID strin
|
|||
},
|
||||
}
|
||||
|
||||
gql := graphQLClient(httpClient, repo.RepoHost())
|
||||
return gql.MutateNamed(context.Background(), "PullRequestClose", &mutation, variables)
|
||||
client := NewClientFromHTTP(httpClient)
|
||||
return client.Mutate(repo.RepoHost(), "PullRequestClose", &mutation, variables)
|
||||
}
|
||||
|
||||
func PullRequestReopen(httpClient *http.Client, repo ghrepo.Interface, prID string) error {
|
||||
|
|
@ -412,8 +410,8 @@ func PullRequestReopen(httpClient *http.Client, repo ghrepo.Interface, prID stri
|
|||
},
|
||||
}
|
||||
|
||||
gql := graphQLClient(httpClient, repo.RepoHost())
|
||||
return gql.MutateNamed(context.Background(), "PullRequestReopen", &mutation, variables)
|
||||
client := NewClientFromHTTP(httpClient)
|
||||
return client.Mutate(repo.RepoHost(), "PullRequestReopen", &mutation, variables)
|
||||
}
|
||||
|
||||
func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
|
||||
|
|
@ -431,8 +429,7 @@ func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) er
|
|||
},
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
return gql.MutateNamed(context.Background(), "PullRequestReadyForReview", &mutation, variables)
|
||||
return client.Mutate(repo.RepoHost(), "PullRequestReadyForReview", &mutation, variables)
|
||||
}
|
||||
|
||||
func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) error {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
|
|
@ -65,8 +64,7 @@ func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *Pu
|
|||
},
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables)
|
||||
return client.Mutate(repo.RepoHost(), "PullRequestReviewAdd", &mutation, variables)
|
||||
}
|
||||
|
||||
func (prr PullRequestReview) AuthorLogin() string {
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ func TestBranchDeleteRemote(t *testing.T) {
|
|||
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/branch"),
|
||||
httpmock.StatusStringResponse(tt.responseStatus, tt.responseBody))
|
||||
|
||||
client := NewClient(ReplaceTripper(http))
|
||||
client := newTestClient(http)
|
||||
repo, _ := ghrepo.FromFullName("OWNER/REPO")
|
||||
|
||||
err := BranchDeleteRemote(client, repo, "branch")
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package api
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
|
@ -14,6 +13,7 @@ import (
|
|||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
ghAPI "github.com/cli/go-gh/pkg/api"
|
||||
"github.com/shurcooL/githubv4"
|
||||
)
|
||||
|
||||
|
|
@ -254,11 +254,13 @@ func FetchRepository(client *Client, repo ghrepo.Interface, fields []string) (*R
|
|||
// The GraphQL API should have returned an error in case of a missing repository, but this isn't
|
||||
// guaranteed to happen when an authentication token with insufficient permissions is being used.
|
||||
if result.Repository == nil {
|
||||
return nil, GraphQLErrorResponse{
|
||||
Errors: []GraphQLError{{
|
||||
Type: "NOT_FOUND",
|
||||
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
|
||||
}},
|
||||
return nil, GraphQLError{
|
||||
GQLError: ghAPI.GQLError{
|
||||
Errors: []ghAPI.GQLErrorItem{{
|
||||
Type: "NOT_FOUND",
|
||||
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,11 +307,13 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
|
|||
// The GraphQL API should have returned an error in case of a missing repository, but this isn't
|
||||
// guaranteed to happen when an authentication token with insufficient permissions is being used.
|
||||
if result.Repository == nil {
|
||||
return nil, GraphQLErrorResponse{
|
||||
Errors: []GraphQLError{{
|
||||
Type: "NOT_FOUND",
|
||||
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
|
||||
}},
|
||||
return nil, GraphQLError{
|
||||
GQLError: ghAPI.GQLError{
|
||||
Errors: []ghAPI.GQLErrorItem{{
|
||||
Type: "NOT_FOUND",
|
||||
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
|
||||
}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -359,8 +363,7 @@ func RepoParent(client *Client, repo ghrepo.Interface) (ghrepo.Interface, error)
|
|||
"name": githubv4.String(repo.RepoName()),
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
err := gql.QueryNamed(context.Background(), "RepositoryFindParent", &query, variables)
|
||||
err := client.Query(repo.RepoHost(), "RepositoryFindParent", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -419,7 +422,7 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e
|
|||
%s
|
||||
}
|
||||
`, strings.Join(queries, "")), nil, &graphqlResult)
|
||||
graphqlError, isGraphQLError := err.(*GraphQLErrorResponse)
|
||||
graphqlError, isGraphQLError := err.(*GraphQLError)
|
||||
if isGraphQLError {
|
||||
// If the only errors are that certain repositories are not found,
|
||||
// continue processing this response instead of returning an error
|
||||
|
|
@ -581,8 +584,7 @@ func LastCommit(client *Client, repo ghrepo.Interface) (*Commit, error) {
|
|||
variables := map[string]interface{}{
|
||||
"owner": githubv4.String(repo.RepoOwner()), "repo": githubv4.String(repo.RepoName()),
|
||||
}
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
if err := gql.QueryNamed(context.Background(), "LastCommit", &responseData, variables); err != nil {
|
||||
if err := client.Query(repo.RepoHost(), "LastCommit", &responseData, variables); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &responseData.Repository.DefaultBranchRef.Target.Commit, nil
|
||||
|
|
@ -987,12 +989,10 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error)
|
|||
"endCursor": (*githubv4.String)(nil),
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
|
||||
var projects []RepoProject
|
||||
for {
|
||||
var query responseData
|
||||
err := gql.QueryNamed(context.Background(), "RepositoryProjectList", &query, variables)
|
||||
err := client.Query(repo.RepoHost(), "RepositoryProjectList", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -1058,12 +1058,10 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee,
|
|||
"endCursor": (*githubv4.String)(nil),
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
|
||||
var users []RepoAssignee
|
||||
for {
|
||||
var query responseData
|
||||
err := gql.QueryNamed(context.Background(), "RepositoryAssignableUsers", &query, variables)
|
||||
err := client.Query(repo.RepoHost(), "RepositoryAssignableUsers", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -1103,12 +1101,10 @@ func RepoLabels(client *Client, repo ghrepo.Interface) ([]RepoLabel, error) {
|
|||
"endCursor": (*githubv4.String)(nil),
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
|
||||
var labels []RepoLabel
|
||||
for {
|
||||
var query responseData
|
||||
err := gql.QueryNamed(context.Background(), "RepositoryLabelList", &query, variables)
|
||||
err := client.Query(repo.RepoHost(), "RepositoryLabelList", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -1161,12 +1157,10 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo
|
|||
"endCursor": (*githubv4.String)(nil),
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
|
||||
var milestones []RepoMilestone
|
||||
for {
|
||||
var query responseData
|
||||
err := gql.QueryNamed(context.Background(), "RepositoryMilestoneList", &query, variables)
|
||||
err := client.Query(repo.RepoHost(), "RepositoryMilestoneList", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ func TestGitHubRepo_notFound(t *testing.T) {
|
|||
httpmock.GraphQL(`query RepositoryInfo\b`),
|
||||
httpmock.StringResponse(`{ "data": { "repository": null } }`))
|
||||
|
||||
client := NewClient(ReplaceTripper(httpReg))
|
||||
client := newTestClient(httpReg)
|
||||
repo, err := GitHubRepo(client, ghrepo.New("OWNER", "REPO"))
|
||||
if err == nil {
|
||||
t.Fatal("GitHubRepo did not return an error")
|
||||
|
|
@ -33,7 +33,7 @@ func TestGitHubRepo_notFound(t *testing.T) {
|
|||
|
||||
func Test_RepoMetadata(t *testing.T) {
|
||||
http := &httpmock.Registry{}
|
||||
client := NewClient(ReplaceTripper(http))
|
||||
client := newTestClient(http)
|
||||
|
||||
repo, _ := ghrepo.FromFullName("OWNER/REPO")
|
||||
input := RepoMetadataInput{
|
||||
|
|
@ -182,7 +182,7 @@ func Test_ProjectsToPaths(t *testing.T) {
|
|||
|
||||
func Test_ProjectNamesToPaths(t *testing.T) {
|
||||
http := &httpmock.Registry{}
|
||||
client := NewClient(ReplaceTripper(http))
|
||||
client := newTestClient(http)
|
||||
|
||||
repo, _ := ghrepo.FromFullName("OWNER/REPO")
|
||||
|
||||
|
|
@ -221,7 +221,7 @@ func Test_ProjectNamesToPaths(t *testing.T) {
|
|||
|
||||
func Test_RepoResolveMetadataIDs(t *testing.T) {
|
||||
http := &httpmock.Registry{}
|
||||
client := NewClient(ReplaceTripper(http))
|
||||
client := newTestClient(http)
|
||||
|
||||
repo, _ := ghrepo.FromFullName("OWNER/REPO")
|
||||
input := RepoResolveInput{
|
||||
|
|
@ -350,7 +350,7 @@ func Test_RepoMilestones(t *testing.T) {
|
|||
query = buf.String()
|
||||
return httpmock.StringResponse("{}")(req)
|
||||
})
|
||||
client := NewClient(ReplaceTripper(reg))
|
||||
client := newTestClient(reg)
|
||||
|
||||
_, err := RepoMilestones(client, ghrepo.New("OWNER", "REPO"), tt.state)
|
||||
if (err != nil) != tt.wantErr {
|
||||
|
|
|
|||
|
|
@ -1,17 +1,12 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
func CurrentLoginName(client *Client, hostname string) (string, error) {
|
||||
var query struct {
|
||||
Viewer struct {
|
||||
Login string
|
||||
}
|
||||
}
|
||||
gql := graphQLClient(client.http, hostname)
|
||||
err := gql.QueryNamed(context.Background(), "UserCurrent", &query, nil)
|
||||
err := client.Query(hostname, "UserCurrent", &query, nil)
|
||||
return query.Viewer.Login, err
|
||||
}
|
||||
|
||||
|
|
@ -21,7 +16,6 @@ func CurrentUserID(client *Client, hostname string) (string, error) {
|
|||
ID string
|
||||
}
|
||||
}
|
||||
gql := graphQLClient(client.http, hostname)
|
||||
err := gql.QueryNamed(context.Background(), "UserCurrent", &query, nil)
|
||||
err := client.Query(hostname, "UserCurrent", &query, nil)
|
||||
return query.Viewer.ID, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ import (
|
|||
"github.com/cli/cli/v2/git"
|
||||
"github.com/cli/cli/v2/internal/build"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/run"
|
||||
"github.com/cli/cli/v2/internal/update"
|
||||
|
|
@ -30,7 +29,6 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/text"
|
||||
"github.com/cli/cli/v2/utils"
|
||||
"github.com/cli/safeexec"
|
||||
"github.com/mattn/go-colorable"
|
||||
"github.com/mgutz/ansi"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
@ -358,40 +356,19 @@ func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) {
|
|||
if !shouldCheckForUpdate() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
client, err := basicClient(currentVersion)
|
||||
httpClient, err := api.NewHTTPClient(api.HTTPClientOptions{
|
||||
AppVersion: currentVersion,
|
||||
Log: os.Stderr,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
repo := updaterEnabled
|
||||
stateFilePath := filepath.Join(config.StateDir(), "state.yml")
|
||||
return update.CheckForUpdate(client, stateFilePath, repo, currentVersion)
|
||||
}
|
||||
|
||||
// BasicClient returns an API client for github.com only that borrows from but
|
||||
// does not depend on user configuration
|
||||
func basicClient(currentVersion string) (*api.Client, error) {
|
||||
var opts []api.ClientOption
|
||||
if isVerbose, debugValue := utils.IsDebugEnabled(); isVerbose {
|
||||
colorize := utils.IsTerminal(os.Stderr)
|
||||
logTraffic := strings.Contains(debugValue, "api")
|
||||
opts = append(opts, api.VerboseLog(colorable.NewColorable(os.Stderr), logTraffic, colorize))
|
||||
}
|
||||
opts = append(opts, api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", currentVersion)))
|
||||
|
||||
token, _ := config.AuthTokenFromEnv(ghinstance.Default())
|
||||
if token == "" {
|
||||
if c, err := config.ParseDefaultConfig(); err == nil {
|
||||
token, _ = c.Get(ghinstance.Default(), "oauth_token")
|
||||
}
|
||||
}
|
||||
if token != "" {
|
||||
opts = append(opts, api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
|
||||
}
|
||||
return api.NewClient(opts...), nil
|
||||
}
|
||||
|
||||
func isRecentRelease(publishedAt time.Time) bool {
|
||||
return !publishedAt.IsZero() && time.Since(publishedAt) < time.Hour*24
|
||||
}
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -9,7 +9,7 @@ require (
|
|||
github.com/charmbracelet/glamour v0.4.0
|
||||
github.com/charmbracelet/lipgloss v0.5.0
|
||||
github.com/cli/browser v1.1.0
|
||||
github.com/cli/go-gh v0.0.4-0.20220601161125-9dbbfe25d943
|
||||
github.com/cli/go-gh v0.0.4-0.20220614183308-ef2bca923638
|
||||
github.com/cli/oauth v0.9.0
|
||||
github.com/cli/safeexec v1.0.0
|
||||
github.com/cli/shurcooL-graphql v0.0.1
|
||||
|
|
|
|||
6
go.sum
6
go.sum
|
|
@ -58,8 +58,8 @@ github.com/cli/browser v1.1.0 h1:xOZBfkfY9L9vMBgqb1YwRirGu6QFaQ5dP/vXt5ENSOY=
|
|||
github.com/cli/browser v1.1.0/go.mod h1:HKMQAt9t12kov91Mn7RfZxyJQQgWgyS/3SZswlZ5iTI=
|
||||
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03 h1:3f4uHLfWx4/WlnMPXGai03eoWAI+oGHJwr+5OXfxCr8=
|
||||
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
github.com/cli/go-gh v0.0.4-0.20220601161125-9dbbfe25d943 h1:UHPVvUnyHCDBjtdRQEDSscH0XljX+pkDayt+4J0kVFo=
|
||||
github.com/cli/go-gh v0.0.4-0.20220601161125-9dbbfe25d943/go.mod h1:Y/QFb/VxnXQH0W4VlP+507HVxMzQ430x8kdjUuVcono=
|
||||
github.com/cli/go-gh v0.0.4-0.20220614183308-ef2bca923638 h1:7MXhocX2RDlWrjKZ1pZsy8eMNGa3xkZzPrGC1IPBfx4=
|
||||
github.com/cli/go-gh v0.0.4-0.20220614183308-ef2bca923638/go.mod h1:Y/QFb/VxnXQH0W4VlP+507HVxMzQ430x8kdjUuVcono=
|
||||
github.com/cli/oauth v0.9.0 h1:nxBC0Df4tUzMkqffAB+uZvisOwT3/N9FpkfdTDtafxc=
|
||||
github.com/cli/oauth v0.9.0/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
|
||||
github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
|
||||
|
|
@ -146,6 +146,7 @@ github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl
|
|||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
|
|
@ -504,6 +505,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
|||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
|
||||
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
|
|
@ -14,7 +15,10 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/utils"
|
||||
"github.com/cli/go-gh"
|
||||
ghAPI "github.com/cli/go-gh/pkg/api"
|
||||
"github.com/cli/oauth"
|
||||
"github.com/henvic/httpretty"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -22,6 +26,8 @@ var (
|
|||
oauthClientID = "178c6fc778ccc68e1d6a"
|
||||
// This value is safe to be embedded in version control
|
||||
oauthClientSecret = "34ddeff2b558a23d38fba8a6de74f086ede1cc0b"
|
||||
|
||||
jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
|
||||
)
|
||||
|
||||
type iconfig interface {
|
||||
|
|
@ -65,11 +71,11 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
|
|||
w := IO.ErrOut
|
||||
cs := IO.ColorScheme()
|
||||
|
||||
httpClient := http.DefaultClient
|
||||
httpClient := &http.Client{}
|
||||
debugEnabled, debugValue := utils.IsDebugEnabled()
|
||||
if debugEnabled {
|
||||
logTraffic := strings.Contains(debugValue, "api")
|
||||
httpClient.Transport = api.VerboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport)
|
||||
httpClient.Transport = verboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport)
|
||||
}
|
||||
|
||||
minimumScopes := []string{"repo", "read:org", "gist"}
|
||||
|
|
@ -141,8 +147,12 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
|
|||
}
|
||||
|
||||
func getViewer(hostname, token string) (string, error) {
|
||||
http := api.NewClient(api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
|
||||
return api.CurrentLoginName(http, hostname)
|
||||
opts := ghAPI.ClientOptions{Host: hostname, AuthToken: token}
|
||||
client, err := gh.HTTPClient(&opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return api.CurrentLoginName(api.NewClientFromHTTP(client), hostname)
|
||||
}
|
||||
|
||||
func waitForEnter(r io.Reader) error {
|
||||
|
|
@ -150,3 +160,26 @@ func waitForEnter(r io.Reader) error {
|
|||
scanner.Scan()
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func verboseLog(out io.Writer, logTraffic bool, colorize bool) func(http.RoundTripper) http.RoundTripper {
|
||||
logger := &httpretty.Logger{
|
||||
Time: true,
|
||||
TLS: false,
|
||||
Colors: colorize,
|
||||
RequestHeader: logTraffic,
|
||||
RequestBody: logTraffic,
|
||||
ResponseHeader: logTraffic,
|
||||
ResponseBody: logTraffic,
|
||||
Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}},
|
||||
MaxResponseBody: 10000,
|
||||
}
|
||||
logger.SetOutput(out)
|
||||
logger.SetBodyFilter(func(h http.Header) (skip bool, err error) {
|
||||
return !inspectableMIMEType(h.Get("Content-Type")), nil
|
||||
})
|
||||
return logger.RoundTripper
|
||||
}
|
||||
|
||||
func inspectableMIMEType(t string) bool {
|
||||
return strings.HasPrefix(t, "text/") || jsonTypeRE.MatchString(t)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ package featuredetection
|
|||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
graphql "github.com/cli/shurcooL-graphql"
|
||||
)
|
||||
|
|
@ -56,9 +54,8 @@ type detector struct {
|
|||
}
|
||||
|
||||
func NewDetector(httpClient *http.Client, host string) Detector {
|
||||
cachedClient := api.NewCachedClient(httpClient, time.Hour*48)
|
||||
return &detector{
|
||||
httpClient: cachedClient,
|
||||
httpClient: httpClient,
|
||||
host: host,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
package featuredetection
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
|
@ -75,10 +75,11 @@ func TestPullRequestFeatures(t *testing.T) {
|
|||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fakeHTTP := &httpmock.Registry{}
|
||||
httpClient := api.NewHTTPClient(api.ReplaceTripper(fakeHTTP))
|
||||
reg := &httpmock.Registry{}
|
||||
httpClient := &http.Client{}
|
||||
httpmock.ReplaceTripper(httpClient, reg)
|
||||
for query, resp := range tt.queryResponse {
|
||||
fakeHTTP.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
|
||||
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
|
||||
}
|
||||
detector := detector{host: tt.hostname, httpClient: httpClient}
|
||||
gotPrFeatures, err := detector.PullRequestFeatures()
|
||||
|
|
@ -180,10 +181,11 @@ func TestRepositoryFeatures(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fakeHTTP := &httpmock.Registry{}
|
||||
httpClient := api.NewHTTPClient(api.ReplaceTripper(fakeHTTP))
|
||||
reg := &httpmock.Registry{}
|
||||
httpClient := &http.Client{}
|
||||
httpmock.ReplaceTripper(httpClient, reg)
|
||||
for query, resp := range tt.queryResponse {
|
||||
fakeHTTP.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
|
||||
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
|
||||
}
|
||||
detector := detector{host: tt.hostname, httpClient: httpClient}
|
||||
gotPrFeatures, err := detector.RepositoryFeatures()
|
||||
|
|
|
|||
|
|
@ -1,21 +0,0 @@
|
|||
// package httpunix provides an http.RoundTripper which dials a server via a unix socket.
|
||||
package httpunix
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// NewRoundTripper returns an http.RoundTripper which sends requests via a unix
|
||||
// socket at socketPath.
|
||||
func NewRoundTripper(socketPath string) http.RoundTripper {
|
||||
dial := func(network, addr string) (net.Conn, error) {
|
||||
return net.Dial("unix", socketPath)
|
||||
}
|
||||
|
||||
return &http.Transport{
|
||||
Dial: dial,
|
||||
DialTLS: dial,
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package update
|
|||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
|
|
@ -71,10 +72,12 @@ func TestCheckForUpdate(t *testing.T) {
|
|||
|
||||
for _, s := range scenarios {
|
||||
t.Run(s.Name, func(t *testing.T) {
|
||||
http := &httpmock.Registry{}
|
||||
client := api.NewClient(api.ReplaceTripper(http))
|
||||
reg := &httpmock.Registry{}
|
||||
httpClient := &http.Client{}
|
||||
httpmock.ReplaceTripper(httpClient, reg)
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
http.Register(
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"),
|
||||
httpmock.StringResponse(fmt.Sprintf(`{
|
||||
"tag_name": "%s",
|
||||
|
|
@ -87,10 +90,10 @@ func TestCheckForUpdate(t *testing.T) {
|
|||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(http.Requests) != 1 {
|
||||
t.Fatalf("expected 1 HTTP request, got %d", len(http.Requests))
|
||||
if len(reg.Requests) != 1 {
|
||||
t.Fatalf("expected 1 HTTP request, got %d", len(reg.Requests))
|
||||
}
|
||||
requestPath := http.Requests[0].URL.Path
|
||||
requestPath := reg.Requests[0].URL.Path
|
||||
if requestPath != "/repos/OWNER/REPO/releases/latest" {
|
||||
t.Errorf("HTTP path: %q", requestPath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -269,7 +269,7 @@ func apiRun(opts *ApiOptions) error {
|
|||
return err
|
||||
}
|
||||
if opts.CacheTTL > 0 {
|
||||
httpClient = api.NewCachedClient(httpClient, opts.CacheTTL)
|
||||
httpClient = api.NewCachedHTTPClient(httpClient, opts.CacheTTL)
|
||||
}
|
||||
|
||||
headersOutputStream := opts.IO.Out
|
||||
|
|
|
|||
|
|
@ -912,7 +912,7 @@ func Test_apiRun_cache(t *testing.T) {
|
|||
err = apiRun(&options)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 1, requestCount)
|
||||
assert.Equal(t, 2, requestCount)
|
||||
assert.Equal(t, "", stdout.String(), "stdout")
|
||||
assert.Equal(t, "", stderr.String(), "stderr")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
|
|||
_, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes, interactive)
|
||||
return err
|
||||
},
|
||||
httpClient: http.DefaultClient,
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
|
|
@ -17,6 +18,9 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
)
|
||||
|
||||
var ssoHeader string
|
||||
var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`)
|
||||
|
||||
func New(appVersion string) *cmdutil.Factory {
|
||||
f := &cmdutil.Factory{
|
||||
Config: configFunc(), // No factory dependencies
|
||||
|
|
@ -85,7 +89,17 @@ func httpClientFunc(f *cmdutil.Factory, appVersion string) func() (*http.Client,
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewHTTPClient(io, cfg, appVersion, true)
|
||||
opts := api.HTTPClientOptions{
|
||||
Config: cfg,
|
||||
Log: io.ErrOut,
|
||||
AppVersion: appVersion,
|
||||
}
|
||||
client, err := api.NewHTTPClient(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client.Transport = api.ExtractHeader("X-GitHub-SSO", &ssoHeader)(client.Transport)
|
||||
return client, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -154,7 +168,7 @@ func extensionManager(f *cmdutil.Factory) *extension.Manager {
|
|||
return em
|
||||
}
|
||||
|
||||
em.SetClient(api.NewCachedClient(client, time.Second*30))
|
||||
em.SetClient(api.NewCachedHTTPClient(client, time.Second*30))
|
||||
|
||||
return em
|
||||
}
|
||||
|
|
@ -182,3 +196,16 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams {
|
|||
|
||||
return io
|
||||
}
|
||||
|
||||
// SSOURL returns the URL of a SAML SSO challenge received by the server for clients that use ExtractHeader
|
||||
// to extract the value of the "X-GitHub-SSO" response header.
|
||||
func SSOURL() string {
|
||||
if ssoHeader == "" {
|
||||
return ""
|
||||
}
|
||||
m := ssoURLRE.FindStringSubmatch(ssoHeader)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package factory
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
|
@ -9,7 +11,9 @@ import (
|
|||
"github.com/cli/cli/v2/git"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_BaseRepo(t *testing.T) {
|
||||
|
|
@ -182,6 +186,7 @@ func Test_SmartBaseRepo(t *testing.T) {
|
|||
return tt.config, nil
|
||||
},
|
||||
}
|
||||
f.HttpClient = func() (*http.Client, error) { return nil, nil }
|
||||
f.Remotes = rr.Resolver()
|
||||
f.BaseRepo = SmartBaseRepoFunc(f)
|
||||
repo, err := f.BaseRepo()
|
||||
|
|
@ -452,6 +457,60 @@ func Test_browserLauncher(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestSSOURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
sso string
|
||||
wantStderr string
|
||||
wantSSO string
|
||||
}{
|
||||
{
|
||||
name: "SSO challenge in response header",
|
||||
host: "github.com",
|
||||
sso: "required; url=https://github.com/login/sso?return_to=xyz¶m=123abc; another",
|
||||
wantStderr: "",
|
||||
wantSSO: "https://github.com/login/sso?return_to=xyz¶m=123abc",
|
||||
},
|
||||
}
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if sso := r.URL.Query().Get("sso"); sso != "" {
|
||||
w.Header().Set("X-GitHub-SSO", sso)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := New("1")
|
||||
f.Config = func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
}
|
||||
ios, _, _, stderr := iostreams.Test()
|
||||
f.IOStreams = ios
|
||||
client, err := httpClientFunc(f, "v1.2.3")()
|
||||
require.NoError(t, err)
|
||||
req, err := http.NewRequest("GET", ts.URL, nil)
|
||||
if tt.sso != "" {
|
||||
q := req.URL.Query()
|
||||
q.Set("sso", tt.sso)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
}
|
||||
req.Host = tt.host
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 204, res.StatusCode)
|
||||
assert.Equal(t, tt.wantStderr, stderr.String())
|
||||
assert.Equal(t, tt.wantSSO, SSOURL())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func defaultConfig() config.Config {
|
||||
return config.InheritEnv(config.NewFromString(heredoc.Doc(`
|
||||
hosts:
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
package factory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
"github.com/cli/cli/v2/internal/httpunix"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/utils"
|
||||
)
|
||||
|
||||
var timezoneNames = map[int]string{
|
||||
-39600: "Pacific/Niue",
|
||||
-36000: "Pacific/Honolulu",
|
||||
-34200: "Pacific/Marquesas",
|
||||
-32400: "America/Anchorage",
|
||||
-28800: "America/Los_Angeles",
|
||||
-25200: "America/Chihuahua",
|
||||
-21600: "America/Chicago",
|
||||
-18000: "America/Bogota",
|
||||
-14400: "America/Caracas",
|
||||
-12600: "America/St_Johns",
|
||||
-10800: "America/Argentina/Buenos_Aires",
|
||||
-7200: "Atlantic/South_Georgia",
|
||||
-3600: "Atlantic/Cape_Verde",
|
||||
0: "Europe/London",
|
||||
3600: "Europe/Amsterdam",
|
||||
7200: "Europe/Athens",
|
||||
10800: "Europe/Istanbul",
|
||||
12600: "Asia/Tehran",
|
||||
14400: "Asia/Dubai",
|
||||
16200: "Asia/Kabul",
|
||||
18000: "Asia/Tashkent",
|
||||
19800: "Asia/Kolkata",
|
||||
20700: "Asia/Kathmandu",
|
||||
21600: "Asia/Dhaka",
|
||||
23400: "Asia/Rangoon",
|
||||
25200: "Asia/Bangkok",
|
||||
28800: "Asia/Manila",
|
||||
31500: "Australia/Eucla",
|
||||
32400: "Asia/Tokyo",
|
||||
34200: "Australia/Darwin",
|
||||
36000: "Australia/Brisbane",
|
||||
37800: "Australia/Adelaide",
|
||||
39600: "Pacific/Guadalcanal",
|
||||
43200: "Pacific/Nauru",
|
||||
46800: "Pacific/Auckland",
|
||||
49500: "Pacific/Chatham",
|
||||
50400: "Pacific/Kiritimati",
|
||||
}
|
||||
|
||||
type configGetter interface {
|
||||
Get(string, string) (string, error)
|
||||
}
|
||||
|
||||
// generic authenticated HTTP client for commands
|
||||
func NewHTTPClient(io *iostreams.IOStreams, cfg configGetter, appVersion string, setAccept bool) (*http.Client, error) {
|
||||
var opts []api.ClientOption
|
||||
|
||||
// We need to check and potentially add the unix socket roundtripper option
|
||||
// before adding any other options, since if we are going to use the unix
|
||||
// socket transport, it needs to form the base of the transport chain
|
||||
// represented by invocations of opts...
|
||||
//
|
||||
// Another approach might be to change the signature of api.NewHTTPClient to
|
||||
// take an explicit base http.RoundTripper as its first parameter (it
|
||||
// currently defaults internally to http.DefaultTransport), or add another
|
||||
// variant like api.NewHTTPClientWithBaseRoundTripper. But, the only caller
|
||||
// which would use that non-default behavior is right here, and it doesn't
|
||||
// seem worth the cognitive overhead everywhere else just to serve this one
|
||||
// use case.
|
||||
unixSocket, err := cfg.Get("", "http_unix_socket")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if unixSocket != "" {
|
||||
opts = append(opts, api.ClientOption(func(http.RoundTripper) http.RoundTripper {
|
||||
return httpunix.NewRoundTripper(unixSocket)
|
||||
}))
|
||||
}
|
||||
|
||||
if isVerbose, debugValue := utils.IsDebugEnabled(); isVerbose {
|
||||
logTraffic := strings.Contains(debugValue, "api")
|
||||
opts = append(opts, api.VerboseLog(io.ErrOut, logTraffic, io.IsStderrTTY()))
|
||||
}
|
||||
|
||||
opts = append(opts,
|
||||
api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", appVersion)),
|
||||
api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) {
|
||||
hostname := ghinstance.NormalizeHostname(getHost(req))
|
||||
if token, err := cfg.Get(hostname, "oauth_token"); err == nil && token != "" {
|
||||
return fmt.Sprintf("token %s", token), nil
|
||||
}
|
||||
return "", nil
|
||||
}),
|
||||
api.AddHeaderFunc("Time-Zone", func(req *http.Request) (string, error) {
|
||||
if req.Method != "GET" && req.Method != "HEAD" {
|
||||
if time.Local.String() != "Local" {
|
||||
return time.Local.String(), nil
|
||||
}
|
||||
_, offset := time.Now().Zone()
|
||||
return timezoneNames[offset], nil
|
||||
}
|
||||
return "", nil
|
||||
}),
|
||||
api.ExtractHeader("X-GitHub-SSO", &ssoHeader),
|
||||
)
|
||||
|
||||
if setAccept {
|
||||
opts = append(opts,
|
||||
api.AddHeaderFunc("Accept", func(req *http.Request) (string, error) {
|
||||
accept := "application/vnd.github.merge-info-preview+json" // PullRequest.mergeStateStatus
|
||||
accept += ", application/vnd.github.nebula-preview" // visibility when RESTing repos into an org
|
||||
if ghinstance.IsEnterprise(getHost(req)) {
|
||||
accept += ", application/vnd.github.antiope-preview" // Commit.statusCheckRollup
|
||||
accept += ", application/vnd.github.shadow-cat-preview" // PullRequest.isDraft
|
||||
}
|
||||
return accept, nil
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
return api.NewHTTPClient(opts...), nil
|
||||
}
|
||||
|
||||
var ssoHeader string
|
||||
var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`)
|
||||
|
||||
// SSOURL returns the URL of a SAML SSO challenge received by the server for clients that use ExtractHeader
|
||||
// to extract the value of the "X-GitHub-SSO" response header.
|
||||
func SSOURL() string {
|
||||
if ssoHeader == "" {
|
||||
return ""
|
||||
}
|
||||
m := ssoURLRE.FindStringSubmatch(ssoHeader)
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m[1]
|
||||
}
|
||||
|
||||
func getHost(r *http.Request) string {
|
||||
if r.Host != "" {
|
||||
return r.Host
|
||||
}
|
||||
return r.URL.Hostname()
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ package list
|
|||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
|
|
@ -13,10 +14,12 @@ import (
|
|||
)
|
||||
|
||||
func TestIssueList(t *testing.T) {
|
||||
http := &httpmock.Registry{}
|
||||
client := api.NewClient(api.ReplaceTripper(http))
|
||||
reg := &httpmock.Registry{}
|
||||
httpClient := &http.Client{}
|
||||
httpmock.ReplaceTripper(httpClient, reg)
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
http.Register(
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": {
|
||||
|
|
@ -31,7 +34,7 @@ func TestIssueList(t *testing.T) {
|
|||
} } }
|
||||
`),
|
||||
)
|
||||
http.Register(
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": {
|
||||
|
|
@ -57,15 +60,15 @@ func TestIssueList(t *testing.T) {
|
|||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(http.Requests) != 2 {
|
||||
t.Fatalf("expected 2 HTTP requests, seen %d", len(http.Requests))
|
||||
if len(reg.Requests) != 2 {
|
||||
t.Fatalf("expected 2 HTTP requests, seen %d", len(reg.Requests))
|
||||
}
|
||||
var reqBody struct {
|
||||
Query string
|
||||
Variables map[string]interface{}
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(http.Requests[0].Body)
|
||||
bodyBytes, _ := io.ReadAll(reg.Requests[0].Body)
|
||||
_ = json.Unmarshal(bodyBytes, &reqBody)
|
||||
if reqLimit := reqBody.Variables["limit"].(float64); reqLimit != 100 {
|
||||
t.Errorf("expected 100, got %v", reqLimit)
|
||||
|
|
@ -74,7 +77,7 @@ func TestIssueList(t *testing.T) {
|
|||
t.Error("did not expect first request to pass 'endCursor'")
|
||||
}
|
||||
|
||||
bodyBytes, _ = io.ReadAll(http.Requests[1].Body)
|
||||
bodyBytes, _ = io.ReadAll(reg.Requests[1].Body)
|
||||
_ = json.Unmarshal(bodyBytes, &reqBody)
|
||||
if endCursor := reqBody.Variables["endCursor"].(string); endCursor != "ENDCURSOR" {
|
||||
t.Errorf("expected %q, got %q", "ENDCURSOR", endCursor)
|
||||
|
|
@ -82,10 +85,12 @@ func TestIssueList(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestIssueList_pagination(t *testing.T) {
|
||||
http := &httpmock.Registry{}
|
||||
client := api.NewClient(api.ReplaceTripper(http))
|
||||
reg := &httpmock.Registry{}
|
||||
httpClient := &http.Client{}
|
||||
httpmock.ReplaceTripper(httpClient, reg)
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
http.Register(
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": {
|
||||
|
|
@ -108,7 +113,7 @@ func TestIssueList_pagination(t *testing.T) {
|
|||
`),
|
||||
)
|
||||
|
||||
http.Register(
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": {
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, f
|
|||
var resp response
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
if err := client.GraphQL(repo.RepoHost(), query, variables, &resp); err != nil {
|
||||
var gerr *api.GraphQLErrorResponse
|
||||
var gerr api.GraphQLError
|
||||
if errors.As(err, &gerr) {
|
||||
if gerr.Match("NOT_FOUND", "repository.issue") && !resp.Repository.HasIssuesEnabled {
|
||||
return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
remotes "github.com/cli/cli/v2/context"
|
||||
|
|
@ -141,7 +142,8 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
|
|||
fields.Add("id") // for additional preload queries below
|
||||
|
||||
if fields.Contains("isInMergeQueue") || fields.Contains("isMergeQueueEnabled") {
|
||||
detector := fd.NewDetector(httpClient, f.repo.RepoHost())
|
||||
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
|
||||
detector := fd.NewDetector(cachedClient, f.repo.RepoHost())
|
||||
prFeatures, err := detector.PullRequestFeatures()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import (
|
|||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/git"
|
||||
fd "github.com/cli/cli/v2/internal/featuredetection"
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
|
|
@ -130,13 +132,14 @@ type templateManager struct {
|
|||
}
|
||||
|
||||
func NewTemplateManager(httpClient *http.Client, repo ghrepo.Interface, dir string, allowFS bool, isPR bool) *templateManager {
|
||||
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
|
||||
return &templateManager{
|
||||
repo: repo,
|
||||
rootDir: dir,
|
||||
allowFS: allowFS,
|
||||
isPR: isPR,
|
||||
httpClient: httpClient,
|
||||
detector: fd.NewDetector(httpClient, repo.RepoHost()),
|
||||
detector: fd.NewDetector(cachedClient, repo.RepoHost()),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
|
|
@ -162,7 +163,8 @@ func editRun(ctx context.Context, opts *EditOptions) error {
|
|||
if opts.InteractiveMode {
|
||||
detector := opts.Detector
|
||||
if detector == nil {
|
||||
detector = fd.NewDetector(opts.HTTPClient, repo.RepoHost())
|
||||
cachedClient := api.NewCachedHTTPClient(opts.HTTPClient, time.Hour*24)
|
||||
detector = fd.NewDetector(cachedClient, repo.RepoHost())
|
||||
}
|
||||
repoFeatures, err := detector.RepositoryFeatures()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -125,7 +125,8 @@ func listRun(opts *ListOptions) error {
|
|||
}
|
||||
|
||||
if opts.Detector == nil {
|
||||
opts.Detector = fd.NewDetector(httpClient, host)
|
||||
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
|
||||
opts.Detector = fd.NewDetector(cachedClient, host)
|
||||
}
|
||||
features, err := opts.Detector.RepositoryFeatures()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"sync"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
codespacesAPI "github.com/cli/cli/v2/internal/codespaces/api"
|
||||
actionsCmd "github.com/cli/cli/v2/pkg/cmd/actions"
|
||||
aliasCmd "github.com/cli/cli/v2/pkg/cmd/alias"
|
||||
|
|
@ -127,7 +128,13 @@ func bareHTTPClient(f *cmdutil.Factory, version string) func() (*http.Client, er
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return factory.NewHTTPClient(f.IOStreams, cfg, version, false)
|
||||
opts := api.HTTPClientOptions{
|
||||
AppVersion: version,
|
||||
Config: cfg,
|
||||
Log: f.IOStreams.ErrOut,
|
||||
SkipAcceptHeaders: true,
|
||||
}
|
||||
return api.NewHTTPClient(opts)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/set"
|
||||
"github.com/cli/cli/v2/utils"
|
||||
ghAPI "github.com/cli/go-gh/pkg/api"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
|
@ -40,7 +41,7 @@ type StatusOptions struct {
|
|||
func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
|
||||
opts := &StatusOptions{
|
||||
CachedClient: func(c *http.Client, ttl time.Duration) *http.Client {
|
||||
return api.NewCachedClient(c, ttl)
|
||||
return api.NewCachedHTTPClient(c, ttl)
|
||||
},
|
||||
}
|
||||
opts.HttpClient = f.HttpClient
|
||||
|
|
@ -426,9 +427,9 @@ func (s *StatusGetter) LoadSearchResults() error {
|
|||
}
|
||||
err := c.GraphQL(s.hostname(), searchQuery, variables, &resp)
|
||||
if err != nil {
|
||||
var gqlErrResponse *api.GraphQLErrorResponse
|
||||
var gqlErrResponse api.GraphQLError
|
||||
if errors.As(err, &gqlErrResponse) {
|
||||
gqlErrors := make([]api.GraphQLError, 0, len(gqlErrResponse.Errors))
|
||||
gqlErrors := make([]ghAPI.GQLErrorItem, 0, len(gqlErrResponse.Errors))
|
||||
// Exclude any FORBIDDEN errors and show status for what we can.
|
||||
for _, gqlErr := range gqlErrResponse.Errors {
|
||||
if gqlErr.Type == "FORBIDDEN" {
|
||||
|
|
@ -440,7 +441,11 @@ func (s *StatusGetter) LoadSearchResults() error {
|
|||
if len(gqlErrors) == 0 {
|
||||
err = nil
|
||||
} else {
|
||||
err = &api.GraphQLErrorResponse{Errors: gqlErrors}
|
||||
err = &api.GraphQLError{
|
||||
GQLError: ghAPI.GQLError{
|
||||
Errors: gqlErrors,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ import (
|
|||
"sync"
|
||||
)
|
||||
|
||||
// Replace http.Client transport layer with registry so all requests get
|
||||
// recorded.
|
||||
func ReplaceTripper(client *http.Client, reg *Registry) {
|
||||
client.Transport = reg
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
mu sync.Mutex
|
||||
stubs []*Stub
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue