diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a852ac6..c3d32d6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,7 +10,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v3 with: - go-version: "1.19" + go-version: "1.20" - name: Checkout repository uses: actions/checkout@v3 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 899db28..158be31 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - go: ["1.19"] + go: ["1.20"] runs-on: ${{ matrix.os }} diff --git a/README.md b/README.md index 8b59400..240d1f6 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,19 @@ Modules from this library will obey GitHub CLI conventions by default: -- [`CurrentRepository()`](https://pkg.go.dev/github.com/cli/go-gh#CurrentRepository) respects the value of the `GH_REPO` environment variable and reads from git remote configuration as fallback. +- [`repository.Current()`](https://pkg.go.dev/github.com/cli/go-gh/v2/pkg/repository#current) respects the value of the `GH_REPO` environment variable and reads from git remote configuration as fallback. - GitHub API requests will be authenticated using the same mechanism as `gh`, i.e. using the values of `GH_TOKEN` and `GH_HOST` environment variables and falling back to the user's stored OAuth token. -- [Terminal capabilities](https://pkg.go.dev/github.com/cli/go-gh/pkg/term) are determined by taking environment variables `GH_FORCE_TTY`, `NO_COLOR`, `CLICOLOR`, etc. into account. +- [Terminal capabilities](https://pkg.go.dev/github.com/cli/go-gh/v2/pkg/term) are determined by taking environment variables `GH_FORCE_TTY`, `NO_COLOR`, `CLICOLOR`, etc. into account. -- Generating [table](https://pkg.go.dev/github.com/cli/go-gh/pkg/tableprinter) or [Go template](https://pkg.go.dev/github.com/cli/go-gh/pkg/template) output uses the same engine as gh. +- Generating [table](https://pkg.go.dev/github.com/cli/go-gh/v2/pkg/tableprinter) or [Go template](https://pkg.go.dev/github.com/cli/go-gh/pkg/template) output uses the same engine as gh. -- The [`browser`](https://pkg.go.dev/github.com/cli/go-gh/pkg/browser) module activates the user's preferred web browser. +- The [`browser`](https://pkg.go.dev/github.com/cli/go-gh/v2/pkg/browser) module activates the user's preferred web browser. ## Usage -See the full `go-gh` [reference documentation](https://pkg.go.dev/github.com/cli/go-gh) for more information +See the full `go-gh` [reference documentation](https://pkg.go.dev/github.com/cli/go-gh/v2) for more information ```golang package main @@ -24,21 +24,21 @@ package main import ( "fmt" "log" - "github.com/cli/go-gh" + "github.com/cli/go-gh/v2" ) func main() { - // These examples assume `gh` is installed and has been authenticated + // These examples assume `gh` is installed and has been authenticated. - // Shell out to a gh command and read its output + // Shell out to a gh command and read its output. issueList, _, err := gh.Exec("issue", "list", "--repo", "cli/cli", "--limit", "5") if err != nil { log.Fatal(err) } fmt.Println(issueList.String()) - - // Use an API helper to grab repository tags - client, err := gh.RESTClient(nil) + + // Use an API client to retrieve repository tags. + client, err := api.DefaultRESTClient() if err != nil { log.Fatal(err) } @@ -59,7 +59,6 @@ See [examples][] for more demonstrations of usage. If anything feels off, or if you feel that some functionality is missing, please check out our [contributing docs][contributing]. There you will find instructions for sharing your feedback and for submitting pull requests to the project. Thank you! - [extensions]: https://docs.github.com/en/github-cli/github-cli/creating-github-cli-extensions [examples]: ./example_gh_test.go [contributing]: ./.github/CONTRIBUTING.md diff --git a/example_gh_test.go b/example_gh_test.go index 496ce56..66e9887 100644 --- a/example_gh_test.go +++ b/example_gh_test.go @@ -10,10 +10,11 @@ import ( "regexp" "time" - gh "github.com/cli/go-gh" - "github.com/cli/go-gh/pkg/api" - "github.com/cli/go-gh/pkg/tableprinter" - "github.com/cli/go-gh/pkg/term" + gh "github.com/cli/go-gh/v2" + "github.com/cli/go-gh/v2/pkg/api" + "github.com/cli/go-gh/v2/pkg/repository" + "github.com/cli/go-gh/v2/pkg/tableprinter" + "github.com/cli/go-gh/v2/pkg/term" graphql "github.com/cli/shurcooL-graphql" ) @@ -29,8 +30,8 @@ func ExampleExec() { } // Get tags from cli/cli repository using REST API. -func ExampleRESTClient_simple() { - client, err := gh.RESTClient(nil) +func ExampleDefaultRESTClient() { + client, err := api.DefaultRESTClient() if err != nil { log.Fatal(err) } @@ -44,14 +45,14 @@ func ExampleRESTClient_simple() { // Get tags from cli/cli repository using REST API. // Specifying host, auth token, headers and logging to stdout. -func ExampleRESTClient_advanced() { +func ExampleRESTClient() { opts := api.ClientOptions{ Host: "github.com", AuthToken: "xxxxxxxxxx", // Replace with valid auth token. Headers: map[string]string{"Time-Zone": "America/Los_Angeles"}, Log: os.Stdout, } - client, err := gh.RESTClient(&opts) + client, err := api.NewRESTClient(opts) if err != nil { log.Fatal(err) } @@ -68,7 +69,7 @@ func ExampleRESTClient_request() { opts := api.ClientOptions{ Headers: map[string]string{"Accept": "application/octet-stream"}, } - client, err := gh.RESTClient(&opts) + client, err := api.NewRESTClient(opts) if err != nil { log.Fatal(err) } @@ -102,7 +103,7 @@ func ExampleRESTClient_pagination() { } return "", false } - client, err := gh.RESTClient(nil) + client, err := api.DefaultRESTClient() if err != nil { log.Fatal(err) } @@ -132,9 +133,9 @@ func ExampleRESTClient_pagination() { } } -// Query tags from cli/cli repository using GQL API. -func ExampleGQLClient_simple() { - client, err := gh.GQLClient(nil) +// Query tags from cli/cli repository using GraphQL API. +func ExampleDefaultGraphQLClient() { + client, err := api.DefaultGraphQLClient() if err != nil { log.Fatal(err) } @@ -160,14 +161,14 @@ func ExampleGQLClient_simple() { fmt.Println(query) } -// Query tags from cli/cli repository using GQL API. +// Query tags from cli/cli repository using GraphQL API. // Enable caching and request timeout. -func ExampleGQLClient_advanced() { +func ExampleGraphQLClient() { opts := api.ClientOptions{ EnableCache: true, Timeout: 5 * time.Second, } - client, err := gh.GQLClient(&opts) + client, err := api.NewGraphQLClient(opts) if err != nil { log.Fatal(err) } @@ -193,9 +194,9 @@ func ExampleGQLClient_advanced() { fmt.Println(query) } -// Add a star to the cli/go-gh repository using the GQL API. -func ExampleGQLClient_mutate_simple() { - client, err := gh.GQLClient(nil) +// Add a star to the cli/go-gh repository using the GraphQL API. +func ExampleGraphQLClient_mutate() { + client, err := api.DefaultGraphQLClient() if err != nil { log.Fatal(err) } @@ -228,9 +229,9 @@ func ExampleGQLClient_mutate_simple() { fmt.Println(mutation.AddStar.Starrable.Repository.StargazerCount) } -// Query releases from cli/cli repository using GQL API with paginated results. -func ExampleGQLClient_pagination() { - client, err := gh.GQLClient(nil) +// Query releases from cli/cli repository using GraphQL API with paginated results. +func ExampleGraphQLClient_pagination() { + client, err := api.DefaultGraphQLClient() if err != nil { log.Fatal(err) } @@ -268,12 +269,12 @@ func ExampleGQLClient_pagination() { } // Get repository for the current directory. -func ExampleCurrentRepository() { - repo, err := gh.CurrentRepository() +func ExampleCurrent() { + repo, err := repository.Current() if err != nil { log.Fatal(err) } - fmt.Printf("%s/%s/%s\n", repo.Host(), repo.Owner(), repo.Name()) + fmt.Printf("%s/%s/%s\n", repo.Host, repo.Owner, repo.Name) } // Print tabular data to a terminal or in machine-readable format for scripts. diff --git a/gh.go b/gh.go index 86cc400..a4c900f 100644 --- a/gh.go +++ b/gh.go @@ -8,21 +8,11 @@ package gh import ( "bytes" "context" - "errors" "fmt" "io" - "net/http" "os" "os/exec" - iapi "github.com/cli/go-gh/internal/api" - "github.com/cli/go-gh/internal/git" - irepo "github.com/cli/go-gh/internal/repository" - "github.com/cli/go-gh/pkg/api" - "github.com/cli/go-gh/pkg/auth" - "github.com/cli/go-gh/pkg/config" - repo "github.com/cli/go-gh/pkg/repository" - "github.com/cli/go-gh/pkg/ssh" "github.com/cli/safeexec" ) @@ -76,127 +66,3 @@ func run(ctx context.Context, ghExe string, env []string, stdin io.Reader, stdou } return nil } - -// RESTClient builds a client to send requests to GitHub REST API endpoints. -// As part of the configuration a hostname, auth token, default set of headers, -// and unix domain socket are resolved from the gh environment configuration. -// These behaviors can be overridden using the opts argument. -func RESTClient(opts *api.ClientOptions) (api.RESTClient, error) { - if opts == nil { - opts = &api.ClientOptions{} - } - if optionsNeedResolution(opts) { - err := resolveOptions(opts) - if err != nil { - return nil, err - } - } - return iapi.NewRESTClient(opts.Host, opts), nil -} - -// GQLClient builds a client to send requests to GitHub GraphQL API endpoints. -// As part of the configuration a hostname, auth token, default set of headers, -// and unix domain socket are resolved from the gh environment configuration. -// These behaviors can be overridden using the opts argument. -func GQLClient(opts *api.ClientOptions) (api.GQLClient, error) { - if opts == nil { - opts = &api.ClientOptions{} - } - if optionsNeedResolution(opts) { - err := resolveOptions(opts) - if err != nil { - return nil, err - } - } - return iapi.NewGQLClient(opts.Host, opts), nil -} - -// HTTPClient builds a client that can be passed to another library. -// As part of the configuration a hostname, auth token, default set of headers, -// and unix domain socket are resolved from the gh environment configuration. -// These behaviors can be overridden using the opts argument. In this instance -// providing opts.Host will not change the destination of your request as it is -// the responsibility of the consumer to configure this. However, if opts.Host -// does not match the request host, the auth token will not be added to the headers. -// This is to protect against the case where tokens could be sent to an arbitrary -// host. -func HTTPClient(opts *api.ClientOptions) (*http.Client, error) { - if opts == nil { - opts = &api.ClientOptions{} - } - if optionsNeedResolution(opts) { - err := resolveOptions(opts) - if err != nil { - return nil, err - } - } - client := iapi.NewHTTPClient(opts) - return &client, nil -} - -// CurrentRepository uses git remotes to determine the GitHub repository -// the current directory is tracking. -func CurrentRepository() (repo.Repository, error) { - override := os.Getenv("GH_REPO") - if override != "" { - return repo.Parse(override) - } - - remotes, err := git.Remotes() - if err != nil { - return nil, err - } - if len(remotes) == 0 { - return nil, errors.New("unable to determine current repository, no git remotes configured for this repository") - } - - translator := ssh.NewTranslator() - for _, r := range remotes { - if r.FetchURL != nil { - r.FetchURL = translator.Translate(r.FetchURL) - } - if r.PushURL != nil { - r.PushURL = translator.Translate(r.PushURL) - } - } - - hosts := auth.KnownHosts() - - filteredRemotes := remotes.FilterByHosts(hosts) - if len(filteredRemotes) == 0 { - return nil, errors.New("unable to determine current repository, none of the git remotes configured for this repository point to a known GitHub host") - } - - r := filteredRemotes[0] - return irepo.New(r.Host, r.Owner, r.Repo), nil -} - -func optionsNeedResolution(opts *api.ClientOptions) bool { - if opts.Host == "" { - return true - } - if opts.AuthToken == "" { - return true - } - if opts.UnixDomainSocket == "" && opts.Transport == nil { - return true - } - return false -} - -func resolveOptions(opts *api.ClientOptions) error { - cfg, _ := config.Read() - if opts.Host == "" { - opts.Host, _ = auth.DefaultHost() - } - if opts.AuthToken == "" { - opts.AuthToken, _ = auth.TokenForHost(opts.Host) - if opts.AuthToken == "" { - return fmt.Errorf("authentication token not found for host %s", opts.Host) - } - } - if opts.UnixDomainSocket == "" && cfg != nil { - opts.UnixDomainSocket, _ = cfg.Get([]string{"http_unix_socket"}) - } - return nil -} diff --git a/gh_test.go b/gh_test.go index bf0694a..8bd11bc 100644 --- a/gh_test.go +++ b/gh_test.go @@ -4,16 +4,11 @@ import ( "bytes" "context" "fmt" - "net/http" "os" - "strings" "testing" "time" - "github.com/cli/go-gh/pkg/api" - "github.com/cli/go-gh/pkg/config" "github.com/stretchr/testify/assert" - "gopkg.in/h2non/gock.v1" ) func TestHelperProcess(t *testing.T) { @@ -71,266 +66,3 @@ func TestRunInteractiveContextCanceled(t *testing.T) { []string{"-test.run=TestHelperProcessLongRunning", "--", "gh", "issue", "list"}) assert.EqualError(t, err, "gh execution failed: context deadline exceeded") } - -func TestRESTClient(t *testing.T) { - stubConfig(t, testConfig()) - t.Cleanup(gock.Off) - - gock.New("https://api.github.com"). - Get("/some/test/path"). - MatchHeader("Authorization", "token abc123"). - Reply(200). - JSON(`{"message": "success"}`) - - client, err := RESTClient(nil) - assert.NoError(t, err) - - res := struct{ Message string }{} - err = client.Do("GET", "some/test/path", nil, &res) - assert.NoError(t, err) - assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) - assert.Equal(t, "success", res.Message) -} - -func TestGQLClient(t *testing.T) { - stubConfig(t, testConfig()) - t.Cleanup(gock.Off) - - gock.New("https://api.github.com"). - Post("/graphql"). - MatchHeader("Authorization", "token abc123"). - BodyString(`{"query":"QUERY","variables":{"var":"test"}}`). - Reply(200). - JSON(`{"data":{"viewer":{"login":"hubot"}}}`) - - client, err := GQLClient(nil) - assert.NoError(t, err) - - vars := map[string]interface{}{"var": "test"} - res := struct{ Viewer struct{ Login string } }{} - err = client.Do("QUERY", vars, &res) - assert.NoError(t, err) - assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) - assert.Equal(t, "hubot", res.Viewer.Login) -} - -func TestGQLClientError(t *testing.T) { - stubConfig(t, testConfig()) - t.Cleanup(gock.Off) - - gock.New("https://api.github.com"). - Post("/graphql"). - MatchHeader("Authorization", "token abc123"). - BodyString(`{"query":"QUERY","variables":null}`). - Reply(200). - JSON(`{"errors":[{"type":"NOT_FOUND","path":["organization"],"message":"Could not resolve to an Organization with the login of 'cli'."}]}`) - - client, err := GQLClient(nil) - assert.NoError(t, err) - - res := struct{ Organization struct{ Name string } }{} - err = client.Do("QUERY", nil, &res) - assert.EqualError(t, err, "GraphQL: Could not resolve to an Organization with the login of 'cli'. (organization)") - assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) -} - -func TestHTTPClient(t *testing.T) { - stubConfig(t, testConfig()) - t.Cleanup(gock.Off) - - gock.New("https://api.github.com"). - Get("/some/test/path"). - MatchHeader("Authorization", "token abc123"). - Reply(200). - JSON(`{"message": "success"}`) - - client, err := HTTPClient(nil) - assert.NoError(t, err) - - res, err := client.Get("https://api.github.com/some/test/path") - assert.NoError(t, err) - assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) - assert.Equal(t, 200, res.StatusCode) -} - -func TestResolveOptions(t *testing.T) { - stubConfig(t, testConfigWithSocket()) - - tests := []struct { - name string - opts *api.ClientOptions - wantAuthToken string - wantHost string - wantSocket string - }{ - { - name: "honors consumer provided ClientOptions", - opts: &api.ClientOptions{ - Host: "test.com", - AuthToken: "token_from_opts", - UnixDomainSocket: "socket_from_opts", - }, - wantAuthToken: "token_from_opts", - wantHost: "test.com", - wantSocket: "socket_from_opts", - }, - { - name: "uses config values if there are no consumer provided ClientOptions", - opts: &api.ClientOptions{}, - wantAuthToken: "token", - wantHost: "github.com", - wantSocket: "socket", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := resolveOptions(tt.opts) - assert.NoError(t, err) - assert.Equal(t, tt.wantHost, tt.opts.Host) - assert.Equal(t, tt.wantAuthToken, tt.opts.AuthToken) - assert.Equal(t, tt.wantSocket, tt.opts.UnixDomainSocket) - }) - } -} - -func TestOptionsNeedResolution(t *testing.T) { - tests := []struct { - name string - opts *api.ClientOptions - out bool - }{ - { - name: "Host, AuthToken, and UnixDomainSocket specified", - opts: &api.ClientOptions{ - Host: "test.com", - AuthToken: "token", - UnixDomainSocket: "socket", - }, - out: false, - }, - { - name: "Host, AuthToken, and Transport specified", - opts: &api.ClientOptions{ - Host: "test.com", - AuthToken: "token", - Transport: http.DefaultTransport, - }, - out: false, - }, - { - name: "Host, and AuthToken specified", - opts: &api.ClientOptions{ - Host: "test.com", - AuthToken: "token", - }, - out: true, - }, - { - name: "Host, and UnixDomainSocket specified", - opts: &api.ClientOptions{ - Host: "test.com", - UnixDomainSocket: "socket", - }, - out: true, - }, - { - name: "Host, and Transport specified", - opts: &api.ClientOptions{ - Host: "test.com", - Transport: http.DefaultTransport, - }, - out: true, - }, - { - name: "AuthToken, and UnixDomainSocket specified", - opts: &api.ClientOptions{ - AuthToken: "token", - UnixDomainSocket: "socket", - }, - out: true, - }, - { - name: "AuthToken, and Transport specified", - opts: &api.ClientOptions{ - AuthToken: "token", - Transport: http.DefaultTransport, - }, - out: true, - }, - { - name: "Host specified", - opts: &api.ClientOptions{ - Host: "test.com", - }, - out: true, - }, - { - name: "AuthToken specified", - opts: &api.ClientOptions{ - AuthToken: "token", - }, - out: true, - }, - { - name: "UnixDomainSocket specified", - opts: &api.ClientOptions{ - UnixDomainSocket: "socket", - }, - out: true, - }, - { - name: "Transport specified", - opts: &api.ClientOptions{ - Transport: http.DefaultTransport, - }, - out: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.out, optionsNeedResolution(tt.opts)) - }) - } -} - -func printPendingMocks(mocks []gock.Mock) string { - paths := []string{} - for _, mock := range mocks { - paths = append(paths, mock.Request().URLStruct.String()) - } - return fmt.Sprintf("%d unmatched mocks: %s", len(paths), strings.Join(paths, ", ")) -} - -func stubConfig(t *testing.T, cfgStr string) { - t.Helper() - old := config.Read - config.Read = func() (*config.Config, error) { - return config.ReadFromString(cfgStr), nil - } - t.Cleanup(func() { - config.Read = old - }) -} - -func testConfig() string { - return ` -hosts: - github.com: - user: user1 - oauth_token: abc123 - git_protocol: ssh -` -} - -func testConfigWithSocket() string { - return ` -http_unix_socket: socket -hosts: - github.com: - user: user1 - oauth_token: token - git_protocol: ssh -` -} diff --git a/go.mod b/go.mod index 8555e13..dbb3eb5 100644 --- a/go.mod +++ b/go.mod @@ -1,13 +1,13 @@ -module github.com/cli/go-gh +module github.com/cli/go-gh/v2 -go 1.19 +go 1.20 require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da github.com/cli/browser v1.1.0 github.com/cli/safeexec v1.0.0 - github.com/cli/shurcooL-graphql v0.0.2 + github.com/cli/shurcooL-graphql v0.0.3 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/henvic/httpretty v0.0.6 github.com/itchyny/gojq v0.12.8 diff --git a/go.sum b/go.sum index fa7a7d6..9c75ba2 100644 --- a/go.sum +++ b/go.sum @@ -10,8 +10,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/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= -github.com/cli/shurcooL-graphql v0.0.2 h1:rwP5/qQQ2fM0TzkUTwtt6E2LbIYf6R+39cUXTa04NYk= -github.com/cli/shurcooL-graphql v0.0.2/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA= +github.com/cli/shurcooL-graphql v0.0.3 h1:CtpPxyGDs136/+ZeyAfUKYmcQBjDlq5aqnrDCW5Ghh8= +github.com/cli/shurcooL-graphql v0.0.3/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/api/gql_client.go b/internal/api/gql_client.go deleted file mode 100644 index d1b1e7a..0000000 --- a/internal/api/gql_client.go +++ /dev/null @@ -1,125 +0,0 @@ -package api - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/cli/go-gh/pkg/api" - - graphql "github.com/cli/shurcooL-graphql" -) - -// Implements api.GQLClient interface. -type gqlClient struct { - client *graphql.Client - host string - httpClient *http.Client -} - -func NewGQLClient(host string, opts *api.ClientOptions) api.GQLClient { - httpClient := NewHTTPClient(opts) - endpoint := gqlEndpoint(host) - return gqlClient{ - client: graphql.NewClient(endpoint, &httpClient), - host: endpoint, - httpClient: &httpClient, - } -} - -// DoWithContext executes a single GraphQL query request and populates the response into the data argument. -func (c gqlClient) DoWithContext(ctx context.Context, query string, variables map[string]interface{}, response interface{}) error { - reqBody, err := json.Marshal(map[string]interface{}{"query": query, "variables": variables}) - if err != nil { - return err - } - - req, err := http.NewRequestWithContext(ctx, "POST", c.host, bytes.NewBuffer(reqBody)) - if err != nil { - return err - } - - resp, err := c.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) - } - - if resp.StatusCode == http.StatusNoContent { - return nil - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - gr := gqlResponse{Data: response} - err = json.Unmarshal(body, &gr) - if err != nil { - return err - } - - if len(gr.Errors) > 0 { - return api.GQLError{Errors: gr.Errors} - } - - return nil -} - -// Do wraps DoWithContext using context.Background. -func (c gqlClient) Do(query string, variables map[string]interface{}, response interface{}) error { - return c.DoWithContext(context.Background(), query, variables, response) -} - -// MutateWithContext executes a single GraphQL mutation request, -// with a mutation derived from m, populating the response into it. -// "m" should be a pointer to struct that corresponds to the GitHub GraphQL schema. -func (c gqlClient) MutateWithContext(ctx context.Context, name string, m interface{}, variables map[string]interface{}) error { - return c.client.MutateNamed(ctx, name, m, variables) -} - -// Mutate wraps MutateWithContext using context.Background. -func (c gqlClient) Mutate(name string, m interface{}, variables map[string]interface{}) error { - return c.MutateWithContext(context.Background(), name, m, variables) -} - -// QueryWithContext executes a single GraphQL query request, -// with a query derived from q, populating the response into it. -// "q" should be a pointer to struct that corresponds to the GitHub GraphQL schema. -func (c gqlClient) QueryWithContext(ctx context.Context, name string, q interface{}, variables map[string]interface{}) error { - return c.client.QueryNamed(ctx, name, q, variables) -} - -// Query wraps QueryWithContext using context.Background. -func (c gqlClient) Query(name string, q interface{}, variables map[string]interface{}) error { - return c.QueryWithContext(context.Background(), name, q, variables) -} - -type gqlResponse struct { - Data interface{} - Errors []api.GQLErrorItem -} - -func gqlEndpoint(host string) string { - if isGarage(host) { - return fmt.Sprintf("https://%s/api/graphql", host) - } - host = normalizeHostname(host) - if isEnterprise(host) { - return fmt.Sprintf("https://%s/api/graphql", host) - } - if strings.EqualFold(host, localhost) { - return fmt.Sprintf("http://api.%s/graphql", host) - } - return fmt.Sprintf("https://api.%s/graphql", host) -} diff --git a/internal/api/rest_client.go b/internal/api/rest_client.go deleted file mode 100644 index e04828f..0000000 --- a/internal/api/rest_client.go +++ /dev/null @@ -1,131 +0,0 @@ -package api - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "strings" - - "github.com/cli/go-gh/pkg/api" -) - -// Implements api.RESTClient interface. -type restClient struct { - client http.Client - host string -} - -func NewRESTClient(host string, opts *api.ClientOptions) api.RESTClient { - return restClient{ - client: NewHTTPClient(opts), - host: host, - } -} - -func (c restClient) RequestWithContext(ctx context.Context, method string, path string, body io.Reader) (*http.Response, error) { - url := restURL(c.host, path) - req, err := http.NewRequestWithContext(ctx, method, url, body) - if err != nil { - return nil, err - } - - resp, err := c.client.Do(req) - if err != nil { - return nil, err - } - - success := resp.StatusCode >= 200 && resp.StatusCode < 300 - if !success { - defer resp.Body.Close() - return nil, api.HandleHTTPError(resp) - } - - return resp, err -} - -func (c restClient) Request(method string, path string, body io.Reader) (*http.Response, error) { - return c.RequestWithContext(context.Background(), method, path, body) -} - -func (c restClient) DoWithContext(ctx context.Context, method string, path string, body io.Reader, response interface{}) error { - url := restURL(c.host, path) - req, err := http.NewRequestWithContext(ctx, method, url, body) - if err != nil { - return err - } - - resp, err := c.client.Do(req) - if err != nil { - return err - } - - success := resp.StatusCode >= 200 && resp.StatusCode < 300 - if !success { - defer resp.Body.Close() - return api.HandleHTTPError(resp) - } - - if resp.StatusCode == http.StatusNoContent { - return nil - } - defer resp.Body.Close() - - b, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - err = json.Unmarshal(b, &response) - if err != nil { - return err - } - - return nil -} - -func (c restClient) Do(method string, path string, body io.Reader, response interface{}) error { - return c.DoWithContext(context.Background(), method, path, body, response) -} - -func (c restClient) Delete(path string, resp interface{}) error { - return c.Do(http.MethodDelete, path, nil, resp) -} - -func (c restClient) Get(path string, resp interface{}) error { - return c.Do(http.MethodGet, path, nil, resp) -} - -func (c restClient) Patch(path string, body io.Reader, resp interface{}) error { - return c.Do(http.MethodPatch, path, body, resp) -} - -func (c restClient) Post(path string, body io.Reader, resp interface{}) error { - return c.Do(http.MethodPost, path, body, resp) -} - -func (c restClient) Put(path string, body io.Reader, resp interface{}) error { - return c.Do(http.MethodPut, path, body, resp) -} - -func restURL(hostname string, pathOrURL string) string { - if strings.HasPrefix(pathOrURL, "https://") || strings.HasPrefix(pathOrURL, "http://") { - return pathOrURL - } - return restPrefix(hostname) + pathOrURL -} - -func restPrefix(hostname string) string { - if isGarage(hostname) { - return fmt.Sprintf("https://%s/api/v3/", hostname) - } - hostname = normalizeHostname(hostname) - if isEnterprise(hostname) { - return fmt.Sprintf("https://%s/api/v3/", hostname) - } - if strings.EqualFold(hostname, localhost) { - return fmt.Sprintf("http://api.%s/", hostname) - } - return fmt.Sprintf("https://api.%s/", hostname) -} diff --git a/internal/repository/repository.go b/internal/repository/repository.go deleted file mode 100644 index 2a57acb..0000000 --- a/internal/repository/repository.go +++ /dev/null @@ -1,24 +0,0 @@ -package repository - -func New(host, owner, name string) repo { - return repo{host: host, owner: owner, name: name} -} - -// Implements repository.Repository interface. -type repo struct { - host string - owner string - name string -} - -func (r repo) Host() string { - return r.host -} - -func (r repo) Owner() string { - return r.owner -} - -func (r repo) Name() string { - return r.name -} diff --git a/internal/api/cache.go b/pkg/api/cache.go similarity index 100% rename from internal/api/cache.go rename to pkg/api/cache.go diff --git a/internal/api/cache_test.go b/pkg/api/cache_test.go similarity index 94% rename from internal/api/cache_test.go rename to pkg/api/cache_test.go index 548dc6e..5ae9196 100644 --- a/internal/api/cache_test.go +++ b/pkg/api/cache_test.go @@ -9,7 +9,6 @@ import ( "testing" "time" - "github.com/cli/go-gh/pkg/api" "github.com/stretchr/testify/assert" ) @@ -32,13 +31,17 @@ func TestCacheResponse(t *testing.T) { cacheDir := filepath.Join(t.TempDir(), "gh-cli-cache") - httpClient := NewHTTPClient( - &api.ClientOptions{ + httpClient, err := NewHTTPClient( + ClientOptions{ + Host: "github.com", + AuthToken: "token", Transport: fakeHTTP, EnableCache: true, CacheDir: cacheDir, LogIgnoreEnv: true, - }) + }, + ) + assert.NoError(t, err) do := func(method, url string, body io.Reader) (string, error) { req, err := http.NewRequest(method, url, body) @@ -58,7 +61,6 @@ func TestCacheResponse(t *testing.T) { } var res string - var err error res, err = do("GET", "http://example.com/path", nil) assert.NoError(t, err) @@ -113,13 +115,17 @@ func TestCacheResponseRequestCacheOptions(t *testing.T) { cacheDir := filepath.Join(t.TempDir(), "gh-cli-cache") - httpClient := NewHTTPClient( - &api.ClientOptions{ + httpClient, err := NewHTTPClient( + ClientOptions{ + Host: "github.com", + AuthToken: "token", Transport: fakeHTTP, EnableCache: false, CacheDir: cacheDir, LogIgnoreEnv: true, - }) + }, + ) + assert.NoError(t, err) do := func(method, url string, body io.Reader) (string, error) { req, err := http.NewRequest(method, url, body) @@ -141,7 +147,6 @@ func TestCacheResponseRequestCacheOptions(t *testing.T) { } var res string - var err error res, err = do("GET", "http://example.com/path", nil) assert.NoError(t, err) diff --git a/pkg/api/client.go b/pkg/api/client.go deleted file mode 100644 index 96909e1..0000000 --- a/pkg/api/client.go +++ /dev/null @@ -1,146 +0,0 @@ -// Package api is a set of types for interacting with the GitHub API. -package api - -import ( - "context" - "io" - "net/http" - "time" -) - -// ClientOptions holds available options to configure API clients. -type ClientOptions struct { - // AuthToken is the authorization token that will be used - // to authenticate against API endpoints. - AuthToken string - - // CacheDir is the directory to use for cached API requests. - // Default is the same directory that gh uses for caching. - CacheDir string - - // CacheTTL is the time that cached API requests are valid for. - // Default is 24 hours. - CacheTTL time.Duration - - // EnableCache specifies if API requests will be cached or not. - // Default is no caching. - EnableCache bool - - // Headers are the headers that will be sent with every API request. - // Default headers set are Accept, Content-Type, Time-Zone, and User-Agent. - // Default headers will be overridden by keys specified in Headers. - Headers map[string]string - - // Host is the default host that API requests will be sent to. - Host string - - // Log specifies a writer to write API request logs to. Default is to respect the GH_DEBUG environment - // variable, and no logging otherwise. - Log io.Writer - - // LogIgnoreEnv disables respecting the GH_DEBUG environment variable. This can be useful in test mode - // or when the extension already offers its own controls for logging to the user. - LogIgnoreEnv bool - - // LogColorize enables colorized logging to Log for display in a terminal. - // Default is no coloring. - LogColorize bool - - // LogVerboseHTTP enables logging HTTP headers and bodies to Log. - // Default is only logging request URLs and response statuses. - LogVerboseHTTP bool - - // SkipDefaultHeaders disables setting of the default headers. - SkipDefaultHeaders bool - - // Timeout specifies a time limit for each API request. - // Default is no timeout. - Timeout time.Duration - - // Transport specifies the mechanism by which individual API requests are made. - // If both Transport and UnixDomainSocket are specified then Transport takes - // precedence. Due to this behavior any value set for Transport needs to manually - // handle routing to UnixDomainSocket if necessary. Generally, setting Transport - // should be reserved for testing purposes. - // Default is http.DefaultTransport. - Transport http.RoundTripper - - // UnixDomainSocket specifies the Unix domain socket address by which individual - // API requests will be routed. If specifed, this will form the base of the API - // request transport chain. - // Default is no socket address. - UnixDomainSocket string -} - -// RESTClient is the interface that wraps methods for the different types of -// API requests that are supported by the server. -type RESTClient interface { - // Do wraps DoWithContext with context.Background. - Do(method string, path string, body io.Reader, response interface{}) error - - // DoWithContext issues a request with type specified by method to the - // specified path with the specified body. - // The response is populated into the response argument. - DoWithContext(ctx context.Context, method string, path string, body io.Reader, response interface{}) error - - // Delete issues a DELETE request to the specified path. - // The response is populated into the response argument. - Delete(path string, response interface{}) error - - // Get issues a GET request to the specified path. - // The response is populated into the response argument. - Get(path string, response interface{}) error - - // Patch issues a PATCH request to the specified path with the specified body. - // The response is populated into the response argument. - Patch(path string, body io.Reader, response interface{}) error - - // Post issues a POST request to the specified path with the specified body. - // The response is populated into the response argument. - Post(path string, body io.Reader, response interface{}) error - - // Put issues a PUT request to the specified path with the specified body. - // The response is populated into the response argument. - Put(path string, body io.Reader, response interface{}) error - - // Request wraps RequestWithContext with context.Background. - Request(method string, path string, body io.Reader) (*http.Response, error) - - // RequestWithContext issues a request with type specified by method to the - // specified path with the specified body. - // The response is returned rather than being populated - // into a response argument. - RequestWithContext(ctx context.Context, method string, path string, body io.Reader) (*http.Response, error) -} - -// GQLClient is the interface that wraps methods for the different types of -// API requests that are supported by the server. -type GQLClient interface { - // Do wraps DoWithContext using context.Background. - Do(query string, variables map[string]interface{}, response interface{}) error - - // DoWithContext executes a GraphQL query request. - // The response is populated into the response argument. - DoWithContext(ctx context.Context, query string, variables map[string]interface{}, response interface{}) error - - // Mutate wraps MutateWithContext using context.Background. - Mutate(name string, mutation interface{}, variables map[string]interface{}) error - - // MutateWithContext executes a GraphQL mutation request. - // The mutation string is derived from the mutation argument, and the - // response is populated into it. - // The mutation argument should be a pointer to struct that corresponds - // to the GitHub GraphQL schema. - // Provided input will be set as a variable named input. - MutateWithContext(ctx context.Context, name string, mutation interface{}, variables map[string]interface{}) error - - // Query wraps QueryWithContext using context.Background. - Query(name string, query interface{}, variables map[string]interface{}) error - - // QueryWithContext executes a GraphQL query request, - // The query string is derived from the query argument, and the - // response is populated into it. - // The query argument should be a pointer to struct that corresponds - // to the GitHub GraphQL schema. - QueryWithContext(ctx context.Context, name string, query interface{}, variables map[string]interface{}) error -} diff --git a/pkg/api/client_options.go b/pkg/api/client_options.go new file mode 100644 index 0000000..8ed5937 --- /dev/null +++ b/pkg/api/client_options.go @@ -0,0 +1,106 @@ +// Package api is a set of types for interacting with the GitHub API. +package api + +import ( + "fmt" + "io" + "net/http" + "time" + + "github.com/cli/go-gh/v2/pkg/auth" + "github.com/cli/go-gh/v2/pkg/config" +) + +// ClientOptions holds available options to configure API clients. +type ClientOptions struct { + // AuthToken is the authorization token that will be used + // to authenticate against API endpoints. + AuthToken string + + // CacheDir is the directory to use for cached API requests. + // Default is the same directory that gh uses for caching. + CacheDir string + + // CacheTTL is the time that cached API requests are valid for. + // Default is 24 hours. + CacheTTL time.Duration + + // EnableCache specifies if API requests will be cached or not. + // Default is no caching. + EnableCache bool + + // Headers are the headers that will be sent with every API request. + // Default headers set are Accept, Content-Type, Time-Zone, and User-Agent. + // Default headers will be overridden by keys specified in Headers. + Headers map[string]string + + // Host is the default host that API requests will be sent to. + Host string + + // Log specifies a writer to write API request logs to. Default is to respect the GH_DEBUG environment + // variable, and no logging otherwise. + Log io.Writer + + // LogIgnoreEnv disables respecting the GH_DEBUG environment variable. This can be useful in test mode + // or when the extension already offers its own controls for logging to the user. + LogIgnoreEnv bool + + // LogColorize enables colorized logging to Log for display in a terminal. + // Default is no coloring. + LogColorize bool + + // LogVerboseHTTP enables logging HTTP headers and bodies to Log. + // Default is only logging request URLs and response statuses. + LogVerboseHTTP bool + + // SkipDefaultHeaders disables setting of the default headers. + SkipDefaultHeaders bool + + // Timeout specifies a time limit for each API request. + // Default is no timeout. + Timeout time.Duration + + // Transport specifies the mechanism by which individual API requests are made. + // If both Transport and UnixDomainSocket are specified then Transport takes + // precedence. Due to this behavior any value set for Transport needs to manually + // handle routing to UnixDomainSocket if necessary. Generally, setting Transport + // should be reserved for testing purposes. + // Default is http.DefaultTransport. + Transport http.RoundTripper + + // UnixDomainSocket specifies the Unix domain socket address by which individual + // API requests will be routed. If specifed, this will form the base of the API + // request transport chain. + // Default is no socket address. + UnixDomainSocket string +} + +func optionsNeedResolution(opts ClientOptions) bool { + if opts.Host == "" { + return true + } + if opts.AuthToken == "" { + return true + } + if opts.UnixDomainSocket == "" && opts.Transport == nil { + return true + } + return false +} + +func resolveOptions(opts ClientOptions) (ClientOptions, error) { + cfg, _ := config.Read() + if opts.Host == "" { + opts.Host, _ = auth.DefaultHost() + } + if opts.AuthToken == "" { + opts.AuthToken, _ = auth.TokenForHost(opts.Host) + if opts.AuthToken == "" { + return ClientOptions{}, fmt.Errorf("authentication token not found for host %s", opts.Host) + } + } + if opts.UnixDomainSocket == "" && cfg != nil { + opts.UnixDomainSocket, _ = cfg.Get([]string{"http_unix_socket"}) + } + return opts, nil +} diff --git a/pkg/api/client_options_test.go b/pkg/api/client_options_test.go new file mode 100644 index 0000000..237b9d5 --- /dev/null +++ b/pkg/api/client_options_test.go @@ -0,0 +1,171 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveOptions(t *testing.T) { + stubConfig(t, testConfigWithSocket()) + + tests := []struct { + name string + opts ClientOptions + wantAuthToken string + wantHost string + wantSocket string + }{ + { + name: "honors consumer provided ClientOptions", + opts: ClientOptions{ + Host: "test.com", + AuthToken: "token_from_opts", + UnixDomainSocket: "socket_from_opts", + }, + wantAuthToken: "token_from_opts", + wantHost: "test.com", + wantSocket: "socket_from_opts", + }, + { + name: "uses config values if there are no consumer provided ClientOptions", + opts: ClientOptions{}, + wantAuthToken: "token", + wantHost: "github.com", + wantSocket: "socket", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts, err := resolveOptions(tt.opts) + assert.NoError(t, err) + assert.Equal(t, tt.wantHost, opts.Host) + assert.Equal(t, tt.wantAuthToken, opts.AuthToken) + assert.Equal(t, tt.wantSocket, opts.UnixDomainSocket) + }) + } +} + +func TestOptionsNeedResolution(t *testing.T) { + tests := []struct { + name string + opts ClientOptions + out bool + }{ + { + name: "Host, AuthToken, and UnixDomainSocket specified", + opts: ClientOptions{ + Host: "test.com", + AuthToken: "token", + UnixDomainSocket: "socket", + }, + out: false, + }, + { + name: "Host, AuthToken, and Transport specified", + opts: ClientOptions{ + Host: "test.com", + AuthToken: "token", + Transport: http.DefaultTransport, + }, + out: false, + }, + { + name: "Host, and AuthToken specified", + opts: ClientOptions{ + Host: "test.com", + AuthToken: "token", + }, + out: true, + }, + { + name: "Host, and UnixDomainSocket specified", + opts: ClientOptions{ + Host: "test.com", + UnixDomainSocket: "socket", + }, + out: true, + }, + { + name: "Host, and Transport specified", + opts: ClientOptions{ + Host: "test.com", + Transport: http.DefaultTransport, + }, + out: true, + }, + { + name: "AuthToken, and UnixDomainSocket specified", + opts: ClientOptions{ + AuthToken: "token", + UnixDomainSocket: "socket", + }, + out: true, + }, + { + name: "AuthToken, and Transport specified", + opts: ClientOptions{ + AuthToken: "token", + Transport: http.DefaultTransport, + }, + out: true, + }, + { + name: "Host specified", + opts: ClientOptions{ + Host: "test.com", + }, + out: true, + }, + { + name: "AuthToken specified", + opts: ClientOptions{ + AuthToken: "token", + }, + out: true, + }, + { + name: "UnixDomainSocket specified", + opts: ClientOptions{ + UnixDomainSocket: "socket", + }, + out: true, + }, + { + name: "Transport specified", + opts: ClientOptions{ + Transport: http.DefaultTransport, + }, + out: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.out, optionsNeedResolution(tt.opts)) + }) + } +} + +func testConfig() string { + return ` +hosts: + github.com: + user: user1 + oauth_token: abc123 + git_protocol: ssh +` +} + +func testConfigWithSocket() string { + return ` +http_unix_socket: socket +hosts: + github.com: + user: user1 + oauth_token: token + git_protocol: ssh +` +} diff --git a/pkg/api/errors.go b/pkg/api/errors.go index 57fc400..e8fb93f 100644 --- a/pkg/api/errors.go +++ b/pkg/api/errors.go @@ -6,16 +6,9 @@ import ( "io" "net/http" "net/url" - "regexp" "strings" ) -const ( - contentType = "Content-Type" -) - -var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`) - // HTTPError represents an error response from the GitHub API. type HTTPError struct { Errors []HTTPErrorItem @@ -28,14 +21,14 @@ type HTTPError struct { // HTTPErrorItem stores additional information about an error response // returned from the GitHub API. type HTTPErrorItem struct { + Code string + Field string Message string Resource string - Field string - Code string } // Allow HTTPError to satisfy error interface. -func (err HTTPError) Error() 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 != "" { @@ -44,21 +37,26 @@ func (err HTTPError) Error() string { return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL) } -// GQLError represents an error response from GitHub GraphQL API. -type GQLError struct { - Errors []GQLErrorItem +// GraphQLError represents an error response from GitHub GraphQL API. +type GraphQLError struct { + Errors []GraphQLErrorItem } -// GQLErrorItem stores additional information about an error response +// GraphQLErrorItem stores additional information about an error response // returned from the GitHub GraphQL API. -type GQLErrorItem struct { - Message string - Path []interface{} - Type string +type GraphQLErrorItem struct { + Message string + Locations []struct { + Line int + Column int + } + Path []interface{} + Extensions map[string]interface{} + Type string } -// Allow GQLError to satisfy error interface. -func (gr GQLError) Error() string { +// Allow GraphQLError to satisfy error interface. +func (gr *GraphQLError) Error() string { errorMessages := make([]string, 0, len(gr.Errors)) for _, e := range gr.Errors { msg := e.Message @@ -70,9 +68,9 @@ func (gr GQLError) Error() string { return fmt.Sprintf("GraphQL: %s", strings.Join(errorMessages, ", ")) } -// Match determines if the GQLError is about a specific type on a specific path. +// Match determines if the GraphQLError is about a specific type on a specific path. // If the path argument ends with a ".", it will match all its subpaths. -func (gr GQLError) Match(expectType, expectPath string) bool { +func (gr *GraphQLError) Match(expectType, expectPath string) bool { for _, e := range gr.Errors { if e.Type != expectType || !matchPath(e.pathString(), expectPath) { return false @@ -81,7 +79,7 @@ func (gr GQLError) Match(expectType, expectPath string) bool { return true } -func (ge GQLErrorItem) pathString() string { +func (ge GraphQLErrorItem) pathString() string { var res strings.Builder for i, v := range ge.Path { if i > 0 { @@ -101,7 +99,7 @@ func matchPath(p, expect string) bool { // HandleHTTPError parses a http.Response into a HTTPError. func HandleHTTPError(resp *http.Response) error { - httpError := HTTPError{ + httpError := &HTTPError{ Headers: resp.Header, RequestURL: resp.Request.URL, StatusCode: resp.StatusCode, diff --git a/pkg/api/errors_test.go b/pkg/api/errors_test.go index d1f8783..e20cc4d 100644 --- a/pkg/api/errors_test.go +++ b/pkg/api/errors_test.go @@ -6,17 +6,17 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGQLErrorMatch(t *testing.T) { +func TestGraphQLErrorMatch(t *testing.T) { tests := []struct { name string - error GQLError + error GraphQLError kind string path string wantMatch bool }{ { name: "matches path and type", - error: GQLError{Errors: []GQLErrorItem{ + error: GraphQLError{Errors: []GraphQLErrorItem{ {Path: []interface{}{"repository", "issue"}, Type: "NOT_FOUND"}, }}, kind: "NOT_FOUND", @@ -25,7 +25,7 @@ func TestGQLErrorMatch(t *testing.T) { }, { name: "matches base path and type", - error: GQLError{Errors: []GQLErrorItem{ + error: GraphQLError{Errors: []GraphQLErrorItem{ {Path: []interface{}{"repository", "issue"}, Type: "NOT_FOUND"}, }}, kind: "NOT_FOUND", @@ -34,7 +34,7 @@ func TestGQLErrorMatch(t *testing.T) { }, { name: "does not match path but matches type", - error: GQLError{Errors: []GQLErrorItem{ + error: GraphQLError{Errors: []GraphQLErrorItem{ {Path: []interface{}{"repository", "issue"}, Type: "NOT_FOUND"}, }}, kind: "NOT_FOUND", @@ -43,7 +43,7 @@ func TestGQLErrorMatch(t *testing.T) { }, { name: "matches path but not type", - error: GQLError{Errors: []GQLErrorItem{ + error: GraphQLError{Errors: []GraphQLErrorItem{ {Path: []interface{}{"repository", "issue"}, Type: "NOT_FOUND"}, }}, kind: "UNKNOWN", diff --git a/pkg/api/graphql_client.go b/pkg/api/graphql_client.go new file mode 100644 index 0000000..405b10f --- /dev/null +++ b/pkg/api/graphql_client.go @@ -0,0 +1,182 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + graphql "github.com/cli/shurcooL-graphql" +) + +// GraphQLClient wraps methods for the different types of +// API requests that are supported by the server. +type GraphQLClient struct { + client *graphql.Client + host string + httpClient *http.Client +} + +func DefaultGraphQLClient() (*GraphQLClient, error) { + return NewGraphQLClient(ClientOptions{}) +} + +// GraphQLClient builds a client to send requests to GitHub GraphQL API endpoints. +// As part of the configuration a hostname, auth token, default set of headers, +// and unix domain socket are resolved from the gh environment configuration. +// These behaviors can be overridden using the opts argument. +func NewGraphQLClient(opts ClientOptions) (*GraphQLClient, error) { + if optionsNeedResolution(opts) { + var err error + opts, err = resolveOptions(opts) + if err != nil { + return nil, err + } + } + + httpClient, err := NewHTTPClient(opts) + if err != nil { + return nil, err + } + + endpoint := graphQLEndpoint(opts.Host) + + return &GraphQLClient{ + client: graphql.NewClient(endpoint, httpClient), + host: endpoint, + httpClient: httpClient, + }, nil +} + +// DoWithContext executes a GraphQL query request. +// The response is populated into the response argument. +func (c *GraphQLClient) DoWithContext(ctx context.Context, query string, variables map[string]interface{}, response interface{}) error { + reqBody, err := json.Marshal(map[string]interface{}{"query": query, "variables": variables}) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.host, bytes.NewBuffer(reqBody)) + if err != nil { + return err + } + + resp, err := c.httpClient.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 + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + gr := graphQLResponse{Data: response} + err = json.Unmarshal(body, &gr) + if err != nil { + return err + } + + if len(gr.Errors) > 0 { + return &GraphQLError{Errors: gr.Errors} + } + + return nil +} + +// Do wraps DoWithContext using context.Background. +func (c *GraphQLClient) Do(query string, variables map[string]interface{}, response interface{}) error { + return c.DoWithContext(context.Background(), query, variables, response) +} + +// MutateWithContext executes a GraphQL mutation request. +// The mutation string is derived from the mutation argument, and the +// response is populated into it. +// The mutation argument should be a pointer to struct that corresponds +// to the GitHub GraphQL schema. +// Provided input will be set as a variable named input. +func (c *GraphQLClient) MutateWithContext(ctx context.Context, name string, m interface{}, variables map[string]interface{}) error { + err := c.client.MutateNamed(ctx, name, m, variables) + var graphQLErrs graphql.Errors + if err != nil && errors.As(err, &graphQLErrs) { + items := make([]GraphQLErrorItem, len(graphQLErrs)) + for i, e := range graphQLErrs { + items[i] = GraphQLErrorItem{ + Message: e.Message, + Locations: e.Locations, + Path: e.Path, + Extensions: e.Extensions, + Type: e.Type, + } + } + err = &GraphQLError{items} + } + return err +} + +// Mutate wraps MutateWithContext using context.Background. +func (c *GraphQLClient) Mutate(name string, m interface{}, variables map[string]interface{}) error { + return c.MutateWithContext(context.Background(), name, m, variables) +} + +// QueryWithContext executes a GraphQL query request, +// The query string is derived from the query argument, and the +// response is populated into it. +// The query argument should be a pointer to struct that corresponds +// to the GitHub GraphQL schema. +func (c *GraphQLClient) QueryWithContext(ctx context.Context, name string, q interface{}, variables map[string]interface{}) error { + err := c.client.QueryNamed(ctx, name, q, variables) + var graphQLErrs graphql.Errors + if err != nil && errors.As(err, &graphQLErrs) { + items := make([]GraphQLErrorItem, len(graphQLErrs)) + for i, e := range graphQLErrs { + items[i] = GraphQLErrorItem{ + Message: e.Message, + Locations: e.Locations, + Path: e.Path, + Extensions: e.Extensions, + Type: e.Type, + } + } + err = &GraphQLError{items} + } + return err +} + +// Query wraps QueryWithContext using context.Background. +func (c *GraphQLClient) Query(name string, q interface{}, variables map[string]interface{}) error { + return c.QueryWithContext(context.Background(), name, q, variables) +} + +type graphQLResponse struct { + Data interface{} + Errors []GraphQLErrorItem +} + +func graphQLEndpoint(host string) string { + if isGarage(host) { + return fmt.Sprintf("https://%s/api/graphql", host) + } + host = normalizeHostname(host) + if isEnterprise(host) { + return fmt.Sprintf("https://%s/api/graphql", host) + } + if strings.EqualFold(host, localhost) { + return fmt.Sprintf("http://api.%s/graphql", host) + } + return fmt.Sprintf("https://api.%s/graphql", host) +} diff --git a/internal/api/gql_client_test.go b/pkg/api/graphql_client_test.go similarity index 56% rename from internal/api/gql_client_test.go rename to pkg/api/graphql_client_test.go index 7530cc6..7f9f697 100644 --- a/internal/api/gql_client_test.go +++ b/pkg/api/graphql_client_test.go @@ -2,6 +2,8 @@ package api import ( "context" + "errors" + "net/http" "testing" "time" @@ -9,7 +11,98 @@ import ( "gopkg.in/h2non/gock.v1" ) -func TestGQLClientDo(t *testing.T) { +func TestGraphQLClient(t *testing.T) { + stubConfig(t, testConfig()) + t.Cleanup(gock.Off) + + gock.New("https://api.github.com"). + Post("/graphql"). + MatchHeader("Authorization", "token abc123"). + BodyString(`{"query":"QUERY","variables":{"var":"test"}}`). + Reply(200). + JSON(`{"data":{"viewer":{"login":"hubot"}}}`) + + client, err := DefaultGraphQLClient() + assert.NoError(t, err) + + vars := map[string]interface{}{"var": "test"} + res := struct{ Viewer struct{ Login string } }{} + err = client.Do("QUERY", vars, &res) + assert.NoError(t, err) + assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) + assert.Equal(t, "hubot", res.Viewer.Login) +} + +func TestGraphQLClientDoError(t *testing.T) { + stubConfig(t, testConfig()) + t.Cleanup(gock.Off) + + gock.New("https://api.github.com"). + Post("/graphql"). + MatchHeader("Authorization", "token abc123"). + BodyString(`{"query":"QUERY","variables":null}`). + Reply(200). + JSON(`{"errors":[{"type":"NOT_FOUND","path":["organization"],"message":"Could not resolve to an Organization with the login of 'cli'."}]}`) + + client, err := DefaultGraphQLClient() + assert.NoError(t, err) + + res := struct{ Organization struct{ Name string } }{} + err = client.Do("QUERY", nil, &res) + var graphQLErr *GraphQLError + assert.True(t, errors.As(err, &graphQLErr)) + assert.EqualError(t, graphQLErr, "GraphQL: Could not resolve to an Organization with the login of 'cli'. (organization)") + assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) +} + +func TestGraphQLClientQueryError(t *testing.T) { + stubConfig(t, testConfig()) + t.Cleanup(gock.Off) + + gock.New("https://api.github.com"). + Post("/graphql"). + MatchHeader("Authorization", "token abc123"). + BodyString(`{"query":"query QUERY{organization{name}}"}`). + Reply(200). + JSON(`{"errors":[{"type":"NOT_FOUND","path":["organization"],"message":"Could not resolve to an Organization with the login of 'cli'."}]}`) + + client, err := DefaultGraphQLClient() + assert.NoError(t, err) + + var res struct{ Organization struct{ Name string } } + err = client.Query("QUERY", &res, nil) + var graphQLErr *GraphQLError + assert.True(t, errors.As(err, &graphQLErr)) + assert.EqualError(t, graphQLErr, "GraphQL: Could not resolve to an Organization with the login of 'cli'. (organization)") + assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) +} + +func TestGraphQLClientMutateError(t *testing.T) { + stubConfig(t, testConfig()) + t.Cleanup(gock.Off) + + gock.New("https://api.github.com"). + Post("/graphql"). + MatchHeader("Authorization", "token abc123"). + BodyString(`{"query":"mutation MUTATE($input:ID!){updateRepository{repository{name}}}","variables":{"input":"variables"}}`). + Reply(200). + JSON(`{"errors":[{"type":"NOT_FOUND","path":["organization"],"message":"Could not resolve to an Organization with the login of 'cli'."}]}`) + + client, err := DefaultGraphQLClient() + assert.NoError(t, err) + + var mutation struct { + UpdateRepository struct{ Repository struct{ Name string } } + } + variables := map[string]interface{}{"input": "variables"} + err = client.Mutate("MUTATE", &mutation, variables) + var graphQLErr *GraphQLError + assert.True(t, errors.As(err, &graphQLErr)) + assert.EqualError(t, graphQLErr, "GraphQL: Could not resolve to an Organization with the login of 'cli'. (organization)") + assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) +} + +func TestGraphQLClientDo(t *testing.T) { tests := []struct { name string host string @@ -100,7 +193,11 @@ func TestGQLClientDo(t *testing.T) { if tt.httpMocks != nil { tt.httpMocks() } - client := NewGQLClient(tt.host, nil) + client, _ := NewGraphQLClient(ClientOptions{ + Host: tt.host, + AuthToken: "token", + Transport: http.DefaultTransport, + }) vars := map[string]interface{}{"var": "test"} res := struct{ Viewer struct{ Login string } }{} err := client.Do("QUERY", vars, &res) @@ -115,7 +212,7 @@ func TestGQLClientDo(t *testing.T) { } } -func TestGQLClientDoWithContext(t *testing.T) { +func TestGraphQLClientDoWithContext(t *testing.T) { tests := []struct { name string wantErrMsg string @@ -145,7 +242,6 @@ func TestGQLClientDoWithContext(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // given t.Cleanup(gock.Off) gock.New("https://api.github.com"). Post("/graphql"). @@ -153,22 +249,25 @@ func TestGQLClientDoWithContext(t *testing.T) { Reply(200). JSON(`{}`) - client := NewGQLClient("github.com", nil) + client, _ := NewGraphQLClient(ClientOptions{ + Host: "github.com", + AuthToken: "token", + Transport: http.DefaultTransport, + }) + vars := map[string]interface{}{"var": "test"} res := struct{ Viewer struct{ Login string } }{} - // when ctx := tt.getCtx() gotErr := client.DoWithContext(ctx, "QUERY", vars, &res) - // then assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) assert.EqualError(t, gotErr, tt.wantErrMsg) }) } } -func TestGQLEndpoint(t *testing.T) { +func TestGraphQLEndpoint(t *testing.T) { tests := []struct { name string host string @@ -198,7 +297,7 @@ func TestGQLEndpoint(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - endpoint := gqlEndpoint(tt.host) + endpoint := graphQLEndpoint(tt.host) assert.Equal(t, tt.wantEndpoint, endpoint) }) } diff --git a/internal/api/http.go b/pkg/api/http_client.go similarity index 83% rename from internal/api/http.go rename to pkg/api/http_client.go index 77d4004..dc26e2f 100644 --- a/internal/api/http.go +++ b/pkg/api/http_client.go @@ -11,8 +11,7 @@ import ( "strings" "time" - "github.com/cli/go-gh/pkg/api" - "github.com/cli/go-gh/pkg/term" + "github.com/cli/go-gh/v2/pkg/term" "github.com/henvic/httpretty" "github.com/thlib/go-timezone-local/tzlocal" ) @@ -31,9 +30,26 @@ const ( var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`) -func NewHTTPClient(opts *api.ClientOptions) http.Client { - if opts == nil { - opts = &api.ClientOptions{} +func DefaultHTTPClient() (*http.Client, error) { + return NewHTTPClient(ClientOptions{}) +} + +// HTTPClient builds a client that can be passed to another library. +// As part of the configuration a hostname, auth token, default set of headers, +// and unix domain socket are resolved from the gh environment configuration. +// These behaviors can be overridden using the opts argument. In this instance +// providing opts.Host will not change the destination of your request as it is +// the responsibility of the consumer to configure this. However, if opts.Host +// does not match the request host, the auth token will not be added to the headers. +// This is to protect against the case where tokens could be sent to an arbitrary +// host. +func NewHTTPClient(opts ClientOptions) (*http.Client, error) { + if optionsNeedResolution(opts) { + var err error + opts, err = resolveOptions(opts) + if err != nil { + return nil, err + } } transport := http.DefaultTransport @@ -94,7 +110,7 @@ func NewHTTPClient(opts *api.ClientOptions) http.Client { } transport = newHeaderRoundTripper(opts.Host, opts.AuthToken, opts.Headers, transport) - return http.Client{Transport: transport, Timeout: opts.Timeout} + return &http.Client{Transport: transport, Timeout: opts.Timeout}, nil } func inspectableMIMEType(t string) bool { diff --git a/internal/api/http_test.go b/pkg/api/http_client_test.go similarity index 80% rename from internal/api/http_test.go rename to pkg/api/http_client_test.go index bdfe5b3..3308b39 100644 --- a/internal/api/http_test.go +++ b/pkg/api/http_client_test.go @@ -5,12 +5,33 @@ import ( "fmt" "io" "net/http" + "strings" "testing" - "github.com/cli/go-gh/pkg/api" + "github.com/cli/go-gh/v2/pkg/config" "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" ) +func TestHTTPClient(t *testing.T) { + stubConfig(t, testConfig()) + t.Cleanup(gock.Off) + + gock.New("https://api.github.com"). + Get("/some/test/path"). + MatchHeader("Authorization", "token abc123"). + Reply(200). + JSON(`{"message": "success"}`) + + client, err := DefaultHTTPClient() + assert.NoError(t, err) + + res, err := client.Get("https://api.github.com/some/test/path") + assert.NoError(t, err) + assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) + assert.Equal(t, 200, res.StatusCode) +} + func TestNewHTTPClient(t *testing.T) { reflectHTTP := tripper{ roundTrip: func(req *http.Request) (*http.Response, error) { @@ -114,7 +135,7 @@ func TestNewHTTPClient(t *testing.T) { if tt.host == "" { tt.host = "test.com" } - opts := api.ClientOptions{ + opts := ClientOptions{ Host: tt.host, AuthToken: "oauth_token", Headers: tt.headers, @@ -125,7 +146,7 @@ func TestNewHTTPClient(t *testing.T) { if tt.enableLog { opts.Log = tt.log } - client := NewHTTPClient(&opts) + client, _ := NewHTTPClient(opts) res, err := client.Get("https://test.com") assert.NoError(t, err) assert.Equal(t, tt.wantHeaders, res.Header) @@ -222,3 +243,22 @@ func defaultHeaders() http.Header { h.Set(accept, a) return h } + +func stubConfig(t *testing.T, cfgStr string) { + t.Helper() + old := config.Read + config.Read = func() (*config.Config, error) { + return config.ReadFromString(cfgStr), nil + } + t.Cleanup(func() { + config.Read = old + }) +} + +func printPendingMocks(mocks []gock.Mock) string { + paths := []string{} + for _, mock := range mocks { + paths = append(paths, mock.Request().URLStruct.String()) + } + return fmt.Sprintf("%d unmatched mocks: %s", len(paths), strings.Join(paths, ", ")) +} diff --git a/internal/api/log_formatter.go b/pkg/api/log_formatter.go similarity index 96% rename from internal/api/log_formatter.go rename to pkg/api/log_formatter.go index 26d1a82..e3a85f0 100644 --- a/internal/api/log_formatter.go +++ b/pkg/api/log_formatter.go @@ -7,7 +7,7 @@ import ( "io" "strings" - "github.com/cli/go-gh/pkg/jsonpretty" + "github.com/cli/go-gh/v2/pkg/jsonpretty" ) type graphqlBody struct { diff --git a/pkg/api/rest_client.go b/pkg/api/rest_client.go new file mode 100644 index 0000000..2d91f70 --- /dev/null +++ b/pkg/api/rest_client.go @@ -0,0 +1,170 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" +) + +// RESTClient wraps methods for the different types of +// API requests that are supported by the server. +type RESTClient struct { + client *http.Client + host string +} + +func DefaultRESTClient() (*RESTClient, error) { + return NewRESTClient(ClientOptions{}) +} + +// RESTClient builds a client to send requests to GitHub REST API endpoints. +// As part of the configuration a hostname, auth token, default set of headers, +// and unix domain socket are resolved from the gh environment configuration. +// These behaviors can be overridden using the opts argument. +func NewRESTClient(opts ClientOptions) (*RESTClient, error) { + if optionsNeedResolution(opts) { + var err error + opts, err = resolveOptions(opts) + if err != nil { + return nil, err + } + } + + client, err := NewHTTPClient(opts) + if err != nil { + return nil, err + } + + return &RESTClient{ + client: client, + host: opts.Host, + }, nil +} + +// RequestWithContext issues a request with type specified by method to the +// specified path with the specified body. +// The response is returned rather than being populated +// into a response argument. +func (c *RESTClient) RequestWithContext(ctx context.Context, method string, path string, body io.Reader) (*http.Response, error) { + url := restURL(c.host, path) + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, err + } + + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !success { + defer resp.Body.Close() + return nil, HandleHTTPError(resp) + } + + return resp, err +} + +// Request wraps RequestWithContext with context.Background. +func (c *RESTClient) Request(method string, path string, body io.Reader) (*http.Response, error) { + return c.RequestWithContext(context.Background(), method, path, body) +} + +// DoWithContext issues a request with type specified by method to the +// specified path with the specified body. +// The response is populated into the response argument. +func (c *RESTClient) DoWithContext(ctx context.Context, method string, path string, body io.Reader, response interface{}) error { + url := restURL(c.host, path) + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return err + } + + resp, err := c.client.Do(req) + if err != nil { + return err + } + + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !success { + defer resp.Body.Close() + return HandleHTTPError(resp) + } + + if resp.StatusCode == http.StatusNoContent { + return nil + } + defer resp.Body.Close() + + b, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + err = json.Unmarshal(b, &response) + if err != nil { + return err + } + + return nil +} + +// Do wraps DoWithContext with context.Background. +func (c *RESTClient) Do(method string, path string, body io.Reader, response interface{}) error { + return c.DoWithContext(context.Background(), method, path, body, response) +} + +// Delete issues a DELETE request to the specified path. +// The response is populated into the response argument. +func (c *RESTClient) Delete(path string, resp interface{}) error { + return c.Do(http.MethodDelete, path, nil, resp) +} + +// Get issues a GET request to the specified path. +// The response is populated into the response argument. +func (c *RESTClient) Get(path string, resp interface{}) error { + return c.Do(http.MethodGet, path, nil, resp) +} + +// Patch issues a PATCH request to the specified path with the specified body. +// The response is populated into the response argument. +func (c *RESTClient) Patch(path string, body io.Reader, resp interface{}) error { + return c.Do(http.MethodPatch, path, body, resp) +} + +// Post issues a POST request to the specified path with the specified body. +// The response is populated into the response argument. +func (c *RESTClient) Post(path string, body io.Reader, resp interface{}) error { + return c.Do(http.MethodPost, path, body, resp) +} + +// Put issues a PUT request to the specified path with the specified body. +// The response is populated into the response argument. +func (c *RESTClient) Put(path string, body io.Reader, resp interface{}) error { + return c.Do(http.MethodPut, path, body, resp) +} + +func restURL(hostname string, pathOrURL string) string { + if strings.HasPrefix(pathOrURL, "https://") || strings.HasPrefix(pathOrURL, "http://") { + return pathOrURL + } + return restPrefix(hostname) + pathOrURL +} + +func restPrefix(hostname string) string { + if isGarage(hostname) { + return fmt.Sprintf("https://%s/api/v3/", hostname) + } + hostname = normalizeHostname(hostname) + if isEnterprise(hostname) { + return fmt.Sprintf("https://%s/api/v3/", hostname) + } + if strings.EqualFold(hostname, localhost) { + return fmt.Sprintf("http://api.%s/", hostname) + } + return fmt.Sprintf("https://api.%s/", hostname) +} diff --git a/internal/api/rest_client_test.go b/pkg/api/rest_client_test.go similarity index 84% rename from internal/api/rest_client_test.go rename to pkg/api/rest_client_test.go index c033cbc..edbb9d2 100644 --- a/internal/api/rest_client_test.go +++ b/pkg/api/rest_client_test.go @@ -3,10 +3,8 @@ package api import ( "bytes" "context" - "fmt" "io" "net/http" - "strings" "testing" "time" @@ -14,6 +12,26 @@ import ( "gopkg.in/h2non/gock.v1" ) +func TestRESTClient(t *testing.T) { + stubConfig(t, testConfig()) + t.Cleanup(gock.Off) + + gock.New("https://api.github.com"). + Get("/some/test/path"). + MatchHeader("Authorization", "token abc123"). + Reply(200). + JSON(`{"message": "success"}`) + + client, err := DefaultRESTClient() + assert.NoError(t, err) + + res := struct{ Message string }{} + err = client.Do("GET", "some/test/path", nil, &res) + assert.NoError(t, err) + assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) + assert.Equal(t, "success", res.Message) +} + func TestRESTClientRequest(t *testing.T) { tests := []struct { name string @@ -105,7 +123,11 @@ func TestRESTClientRequest(t *testing.T) { if tt.httpMocks != nil { tt.httpMocks() } - client := NewRESTClient(tt.host, nil) + client, _ := NewRESTClient(ClientOptions{ + Host: tt.host, + AuthToken: "token", + Transport: http.DefaultTransport, + }) resp, err := client.Request("GET", tt.path, nil) if tt.wantErr { @@ -213,7 +235,11 @@ func TestRESTClientDo(t *testing.T) { if tt.httpMocks != nil { tt.httpMocks() } - client := NewRESTClient(tt.host, nil) + client, _ := NewRESTClient(ClientOptions{ + Host: tt.host, + AuthToken: "token", + Transport: http.DefaultTransport, + }) res := struct{ Message string }{} err := client.Do("GET", tt.path, nil, &res) if tt.wantErr { @@ -233,7 +259,11 @@ func TestRESTClientDelete(t *testing.T) { Delete("/some/path/here"). Reply(204). JSON(`{}`) - client := NewRESTClient("github.com", nil) + client, _ := NewRESTClient(ClientOptions{ + Host: "github.com", + AuthToken: "token", + Transport: http.DefaultTransport, + }) err := client.Delete("some/path/here", nil) assert.NoError(t, err) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) @@ -245,7 +275,11 @@ func TestRESTClientGet(t *testing.T) { Get("/some/path/here"). Reply(204). JSON(`{}`) - client := NewRESTClient("github.com", nil) + client, _ := NewRESTClient(ClientOptions{ + Host: "github.com", + AuthToken: "token", + Transport: http.DefaultTransport, + }) err := client.Get("some/path/here", nil) assert.NoError(t, err) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) @@ -258,7 +292,11 @@ func TestRESTClientPatch(t *testing.T) { BodyString(`{}`). Reply(204). JSON(`{}`) - client := NewRESTClient("github.com", nil) + client, _ := NewRESTClient(ClientOptions{ + Host: "github.com", + AuthToken: "token", + Transport: http.DefaultTransport, + }) r := bytes.NewReader([]byte(`{}`)) err := client.Patch("some/path/here", r, nil) assert.NoError(t, err) @@ -272,7 +310,11 @@ func TestRESTClientPost(t *testing.T) { BodyString(`{}`). Reply(204). JSON(`{}`) - client := NewRESTClient("github.com", nil) + client, _ := NewRESTClient(ClientOptions{ + Host: "github.com", + AuthToken: "token", + Transport: http.DefaultTransport, + }) r := bytes.NewReader([]byte(`{}`)) err := client.Post("some/path/here", r, nil) assert.NoError(t, err) @@ -286,7 +328,11 @@ func TestRESTClientPut(t *testing.T) { BodyString(`{}`). Reply(204). JSON(`{}`) - client := NewRESTClient("github.com", nil) + client, _ := NewRESTClient(ClientOptions{ + Host: "github.com", + AuthToken: "token", + Transport: http.DefaultTransport, + }) r := bytes.NewReader([]byte(`{}`)) err := client.Put("some/path/here", r, nil) assert.NoError(t, err) @@ -330,7 +376,11 @@ func TestRESTClientDoWithContext(t *testing.T) { Reply(204). JSON(`{}`) - client := NewRESTClient("github.com", nil) + client, _ := NewRESTClient(ClientOptions{ + Host: "github.com", + AuthToken: "token", + Transport: http.DefaultTransport, + }) res := struct{ Message string }{} // when @@ -381,7 +431,11 @@ func TestRESTClientRequestWithContext(t *testing.T) { Reply(204). JSON(`{}`) - client := NewRESTClient("github.com", nil) + client, _ := NewRESTClient(ClientOptions{ + Host: "github.com", + AuthToken: "token", + Transport: http.DefaultTransport, + }) // when ctx := tt.getCtx() @@ -429,11 +483,3 @@ func TestRestPrefix(t *testing.T) { }) } } - -func printPendingMocks(mocks []gock.Mock) string { - paths := []string{} - for _, mock := range mocks { - paths = append(paths, mock.Request().URLStruct.String()) - } - return fmt.Sprintf("%d unmatched mocks: %s", len(paths), strings.Join(paths, ", ")) -} diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 3d4f86d..50ac4c7 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -8,8 +8,8 @@ import ( "strconv" "strings" - "github.com/cli/go-gh/internal/set" - "github.com/cli/go-gh/pkg/config" + "github.com/cli/go-gh/v2/internal/set" + "github.com/cli/go-gh/v2/pkg/config" "github.com/cli/safeexec" ) diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go index eb3bc9b..5c18112 100644 --- a/pkg/auth/auth_test.go +++ b/pkg/auth/auth_test.go @@ -3,7 +3,7 @@ package auth import ( "testing" - "github.com/cli/go-gh/pkg/config" + "github.com/cli/go-gh/v2/pkg/config" "github.com/stretchr/testify/assert" ) diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go index 1e48b5d..8e8d36f 100644 --- a/pkg/browser/browser.go +++ b/pkg/browser/browser.go @@ -7,7 +7,7 @@ import ( "os/exec" cliBrowser "github.com/cli/browser" - "github.com/cli/go-gh/pkg/config" + "github.com/cli/go-gh/v2/pkg/config" "github.com/cli/safeexec" "github.com/google/shlex" ) @@ -27,11 +27,11 @@ type Browser struct { // - GH_BROWSER environment variable; // - browser option from configuration file; // - BROWSER environment variable. -func New(launcher string, stdout, stderr io.Writer) Browser { +func New(launcher string, stdout, stderr io.Writer) *Browser { if launcher == "" { launcher = resolveLauncher() } - b := Browser{ + b := &Browser{ launcher: launcher, stderr: stderr, stdout: stdout, diff --git a/pkg/browser/browser_test.go b/pkg/browser/browser_test.go index 8733cd5..5c7375d 100644 --- a/pkg/browser/browser_test.go +++ b/pkg/browser/browser_test.go @@ -6,7 +6,7 @@ import ( "os" "testing" - "github.com/cli/go-gh/pkg/config" + "github.com/cli/go-gh/v2/pkg/config" "github.com/stretchr/testify/assert" ) diff --git a/pkg/config/config.go b/pkg/config/config.go index bfbea2b..92e3536 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -11,7 +11,7 @@ import ( "runtime" "sync" - "github.com/cli/go-gh/internal/yamlmap" + "github.com/cli/go-gh/v2/internal/yamlmap" ) const ( @@ -51,7 +51,7 @@ func (c *Config) Get(keys []string) (string, error) { var err error m, err = m.FindEntry(key) if err != nil { - return "", KeyNotFoundError{key} + return "", &KeyNotFoundError{key} } } return m.Value, nil @@ -69,7 +69,7 @@ func (c *Config) Keys(keys []string) ([]string, error) { var err error m, err = m.FindEntry(key) if err != nil { - return nil, KeyNotFoundError{key} + return nil, &KeyNotFoundError{key} } } return m.Keys(), nil @@ -89,12 +89,12 @@ func (c *Config) Remove(keys []string) error { key := keys[i] m, err = m.FindEntry(key) if err != nil { - return KeyNotFoundError{key} + return &KeyNotFoundError{key} } } err := m.RemoveEntry(keys[len(keys)-1]) if err != nil { - return KeyNotFoundError{keys[len(keys)-1]} + return &KeyNotFoundError{keys[len(keys)-1]} } return nil } @@ -179,7 +179,7 @@ func load(generalFilePath, hostsFilePath string) (*Config, error) { if err != nil && !os.IsNotExist(err) { if errors.Is(err, yamlmap.ErrInvalidYaml) || errors.Is(err, yamlmap.ErrInvalidFormat) { - return nil, InvalidConfigFileError{Path: generalFilePath, Err: err} + return nil, &InvalidConfigFileError{Path: generalFilePath, Err: err} } return nil, err } @@ -192,7 +192,7 @@ func load(generalFilePath, hostsFilePath string) (*Config, error) { if err != nil && !os.IsNotExist(err) { if errors.Is(err, yamlmap.ErrInvalidYaml) || errors.Is(err, yamlmap.ErrInvalidFormat) { - return nil, InvalidConfigFileError{Path: hostsFilePath, Err: err} + return nil, &InvalidConfigFileError{Path: hostsFilePath, Err: err} } return nil, err } diff --git a/pkg/config/errors.go b/pkg/config/errors.go index 28120ac..1aefd19 100644 --- a/pkg/config/errors.go +++ b/pkg/config/errors.go @@ -11,12 +11,12 @@ type InvalidConfigFileError struct { } // Allow InvalidConfigFileError to satisfy error interface. -func (e InvalidConfigFileError) Error() string { +func (e *InvalidConfigFileError) Error() string { return fmt.Sprintf("invalid config file %s: %s", e.Path, e.Err) } // Allow InvalidConfigFileError to be unwrapped. -func (e InvalidConfigFileError) Unwrap() error { +func (e *InvalidConfigFileError) Unwrap() error { return e.Err } @@ -27,6 +27,6 @@ type KeyNotFoundError struct { } // Allow KeyNotFoundError to satisfy error interface. -func (e KeyNotFoundError) Error() string { +func (e *KeyNotFoundError) Error() string { return fmt.Sprintf("could not find key %q", e.Key) } diff --git a/pkg/jq/jq.go b/pkg/jq/jq.go index 0154a86..3f01ef4 100644 --- a/pkg/jq/jq.go +++ b/pkg/jq/jq.go @@ -10,7 +10,7 @@ import ( "os" "strconv" - "github.com/cli/go-gh/pkg/jsonpretty" + "github.com/cli/go-gh/v2/pkg/jsonpretty" "github.com/itchyny/gojq" ) diff --git a/pkg/repository/repository.go b/pkg/repository/repository.go index 3e86c9c..2d3400b 100644 --- a/pkg/repository/repository.go +++ b/pkg/repository/repository.go @@ -3,54 +3,67 @@ package repository import ( + "errors" "fmt" + "os" "strings" - "github.com/cli/go-gh/internal/git" - irepo "github.com/cli/go-gh/internal/repository" - "github.com/cli/go-gh/pkg/auth" + "github.com/cli/go-gh/v2/internal/git" + "github.com/cli/go-gh/v2/pkg/auth" + "github.com/cli/go-gh/v2/pkg/ssh" ) -// Repository is the interface that wraps repository information methods. -type Repository interface { - Host() string - Name() string - Owner() string +// Repository holds information representing a GitHub repository. +type Repository struct { + Host string + Name string + Owner string } // Parse extracts the repository information from the following // string formats: "OWNER/REPO", "HOST/OWNER/REPO", and a full URL. // If the format does not specify a host, use the config to determine a host. func Parse(s string) (Repository, error) { + var r Repository + if git.IsURL(s) { u, err := git.ParseURL(s) if err != nil { - return nil, err + return r, err } host, owner, name, err := git.RepoInfoFromURL(u) if err != nil { - return nil, err + return r, err } - return irepo.New(host, owner, name), nil + r.Host = host + r.Name = name + r.Owner = owner + + return r, nil } parts := strings.SplitN(s, "/", 4) for _, p := range parts { if len(p) == 0 { - return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, s) + return r, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, s) } } switch len(parts) { case 3: - return irepo.New(parts[0], parts[1], parts[2]), nil + r.Host = parts[0] + r.Owner = parts[1] + r.Name = parts[2] + return r, nil case 2: - host, _ := auth.DefaultHost() - return irepo.New(host, parts[0], parts[1]), nil + r.Host, _ = auth.DefaultHost() + r.Owner = parts[0] + r.Name = parts[1] + return r, nil default: - return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, s) + return r, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, s) } } @@ -58,33 +71,88 @@ func Parse(s string) (Repository, error) { // string formats: "OWNER/REPO", "HOST/OWNER/REPO", and a full URL. // If the format does not specify a host, use the host provided. func ParseWithHost(s, host string) (Repository, error) { + var r Repository + if git.IsURL(s) { u, err := git.ParseURL(s) if err != nil { - return nil, err + return r, err } host, owner, name, err := git.RepoInfoFromURL(u) if err != nil { - return nil, err + return r, err } - return irepo.New(host, owner, name), nil + r.Host = host + r.Owner = owner + r.Name = name + + return r, nil } parts := strings.SplitN(s, "/", 4) for _, p := range parts { if len(p) == 0 { - return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, s) + return r, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, s) } } switch len(parts) { case 3: - return irepo.New(parts[0], parts[1], parts[2]), nil + r.Host = parts[0] + r.Owner = parts[1] + r.Name = parts[2] + return r, nil case 2: - return irepo.New(host, parts[0], parts[1]), nil + r.Host = host + r.Owner = parts[0] + r.Name = parts[1] + return r, nil default: - return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, s) + return r, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, s) + } +} + +// Current uses git remotes to determine the GitHub repository +// the current directory is tracking. +func Current() (Repository, error) { + var r Repository + + override := os.Getenv("GH_REPO") + if override != "" { + return Parse(override) + } + + remotes, err := git.Remotes() + if err != nil { + return r, err } + if len(remotes) == 0 { + return r, errors.New("unable to determine current repository, no git remotes configured for this repository") + } + + translator := ssh.NewTranslator() + for _, r := range remotes { + if r.FetchURL != nil { + r.FetchURL = translator.Translate(r.FetchURL) + } + if r.PushURL != nil { + r.PushURL = translator.Translate(r.PushURL) + } + } + + hosts := auth.KnownHosts() + + filteredRemotes := remotes.FilterByHosts(hosts) + if len(filteredRemotes) == 0 { + return r, errors.New("unable to determine current repository, none of the git remotes configured for this repository point to a known GitHub host") + } + + rem := filteredRemotes[0] + r.Host = rem.Host + r.Owner = rem.Owner + r.Name = rem.Repo + + return r, nil } diff --git a/pkg/repository/repository_test.go b/pkg/repository/repository_test.go index 839eaf2..54c9918 100644 --- a/pkg/repository/repository_test.go +++ b/pkg/repository/repository_test.go @@ -3,7 +3,7 @@ package repository import ( "testing" - "github.com/cli/go-gh/pkg/config" + "github.com/cli/go-gh/v2/pkg/config" "github.com/stretchr/testify/assert" ) @@ -91,9 +91,9 @@ func TestParse(t *testing.T) { return } assert.NoError(t, err) - assert.Equal(t, tt.wantHost, r.Host()) - assert.Equal(t, tt.wantOwner, r.Owner()) - assert.Equal(t, tt.wantName, r.Name()) + assert.Equal(t, tt.wantHost, r.Host) + assert.Equal(t, tt.wantOwner, r.Owner) + assert.Equal(t, tt.wantName, r.Name) }) } } @@ -109,9 +109,9 @@ hosts: stubConfig(t, cfgStr) r, err := Parse("OWNER/REPO") assert.NoError(t, err) - assert.Equal(t, "enterprise.com", r.Host()) - assert.Equal(t, "OWNER", r.Owner()) - assert.Equal(t, "REPO", r.Name()) + assert.Equal(t, "enterprise.com", r.Host) + assert.Equal(t, "OWNER", r.Owner) + assert.Equal(t, "REPO", r.Name) } func TestParseWithHost(t *testing.T) { @@ -183,9 +183,9 @@ func TestParseWithHost(t *testing.T) { return } assert.NoError(t, err) - assert.Equal(t, tt.wantHost, r.Host()) - assert.Equal(t, tt.wantOwner, r.Owner()) - assert.Equal(t, tt.wantName, r.Name()) + assert.Equal(t, tt.wantHost, r.Host) + assert.Equal(t, tt.wantOwner, r.Owner) + assert.Equal(t, tt.wantName, r.Name) }) } } diff --git a/pkg/tableprinter/table.go b/pkg/tableprinter/table.go index bb38cb8..46d4c4d 100644 --- a/pkg/tableprinter/table.go +++ b/pkg/tableprinter/table.go @@ -9,7 +9,7 @@ import ( "io" "strings" - "github.com/cli/go-gh/pkg/text" + "github.com/cli/go-gh/v2/pkg/text" ) type fieldOption func(*tableField) diff --git a/pkg/template/template.go b/pkg/template/template.go index f698915..a561ebb 100644 --- a/pkg/template/template.go +++ b/pkg/template/template.go @@ -13,8 +13,8 @@ import ( "text/template" "time" - "github.com/cli/go-gh/pkg/tableprinter" - "github.com/cli/go-gh/pkg/text" + "github.com/cli/go-gh/v2/pkg/tableprinter" + "github.com/cli/go-gh/v2/pkg/text" color "github.com/mgutz/ansi" ) @@ -33,8 +33,8 @@ type Template struct { } // New initializes a Template. -func New(w io.Writer, width int, colorEnabled bool) Template { - return Template{ +func New(w io.Writer, width int, colorEnabled bool) *Template { + return &Template{ colorEnabled: colorEnabled, output: w, tp: tableprinter.New(w, true, width), diff --git a/pkg/template/template_test.go b/pkg/template/template_test.go index f17e66d..9e5dcad 100644 --- a/pkg/template/template_test.go +++ b/pkg/template/template_test.go @@ -11,7 +11,7 @@ import ( "time" "github.com/MakeNowJust/heredoc" - "github.com/cli/go-gh/pkg/text" + "github.com/cli/go-gh/v2/pkg/text" "github.com/stretchr/testify/assert" )