pax_global_header00006660000000000000000000000064143772346550014532gustar00rootroot0000000000000052 comment=09213117cf60973e9d69fb5201b2643e5f1ec11b go-gh-1.2.1/000077500000000000000000000000001437723465500125345ustar00rootroot00000000000000go-gh-1.2.1/.github/000077500000000000000000000000001437723465500140745ustar00rootroot00000000000000go-gh-1.2.1/.github/CODE-OF-CONDUCT.md000066400000000000000000000125571437723465500165410ustar00rootroot00000000000000# Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: * Demonstrating empathy and kindness toward other people * Being respectful of differing opinions, viewpoints, and experiences * Giving and gracefully accepting constructive feedback * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience * Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: * The use of sexualized language or imagery, and sexual attention or advances of any kind * Trolling, insulting or derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or email address, without their explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at opensource@github.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations go-gh-1.2.1/.github/CONTRIBUTING.md000066400000000000000000000045051437723465500163310ustar00rootroot00000000000000## Contributing Hi! Thanks for your interest in contributing to the GitHub CLI Module! We accept pull requests for bug fixes and features where we've discussed the approach in an issue and given the go-ahead for a community member to work on it. We'd also love to hear about ideas for new features as issues. Please do: * Check existing issues to verify that the [bug][bug issues] or [feature request][feature request issues] has not already been submitted. * Open an issue if things aren't working as expected. * Open an issue to propose a significant change. * Open a pull request to fix a bug. * Open a pull request to fix documentation. * Open a pull request for any issue labelled [`help wanted`][hw] or [`good first issue`][gfi]. Please avoid: * Opening pull requests for issues marked `needs-design`, `needs-investigation`, or `blocked`. * Opening pull requests for any issue marked `core`. These issues require additional context from the core CLI team at GitHub and any external pull requests will not be accepted. ## Submitting a pull request 1. Create a new branch: `git checkout -b my-branch-name` 1. Make your change, add tests, and ensure tests pass 1. Submit a pull request: `gh pr create --web` Contributions to this project are [released][legal] to the public under the [project's open source license][license]. Please note that this project adheres to a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. ## Resources - [How to Contribute to Open Source][] - [Using Pull Requests][] - [GitHub Help][] [bug issues]: https://github.com/cli/go-gh/issues?q=is%3Aopen+is%3Aissue+label%3Abug [feature request issues]: https://github.com/cli/go-gh/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement [hw]: https://github.com/cli/go-gh/labels/help%20wanted [gfi]: https://github.com/cli/go-gh/labels/good%20first%20issue [legal]: https://docs.github.com/en/free-pro-team@latest/github/site-policy/github-terms-of-service#6-contributions-under-repository-license [license]: ../LICENSE [code-of-conduct]: ./CODE-OF-CONDUCT.md [How to Contribute to Open Source]: https://opensource.guide/how-to-contribute/ [Using Pull Requests]: https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/about-pull-requests [GitHub Help]: https://docs.github.com/ go-gh-1.2.1/.github/workflows/000077500000000000000000000000001437723465500161315ustar00rootroot00000000000000go-gh-1.2.1/.github/workflows/codeql-analysis.yml000066400000000000000000000010611437723465500217420ustar00rootroot00000000000000name: Code Scanning on: push: branches: [trunk] pull_request: branches: [trunk] schedule: - cron: "0 0 * * 0" permissions: actions: read contents: read security-events: write jobs: codeql: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v3 - name: Initialize CodeQL uses: github/codeql-action/init@v2 with: languages: go queries: security-and-quality - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v2 go-gh-1.2.1/.github/workflows/lint.yml000066400000000000000000000007751437723465500176330ustar00rootroot00000000000000name: Lint on: [push, pull_request] permissions: contents: read jobs: lint: runs-on: ubuntu-latest steps: - name: Set up Go uses: actions/setup-go@v3 with: go-version: "1.19" - name: Checkout repository uses: actions/checkout@v3 - name: Check dependencies run: | go mod tidy git diff --exit-code go.mod - name: Lint uses: golangci/golangci-lint-action@v3.1.0 with: version: latest go-gh-1.2.1/.github/workflows/test.yml000066400000000000000000000007501437723465500176350ustar00rootroot00000000000000name: Test on: [push, pull_request] permissions: contents: read jobs: test: strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] go: ["1.19"] runs-on: ${{ matrix.os }} steps: - name: Set up Go uses: actions/setup-go@v3 with: go-version: ${{ matrix.go }} - name: Checkout repository uses: actions/checkout@v3 - name: Run tests run: go test -v ./... go-gh-1.2.1/.golangci.yml000066400000000000000000000003541437723465500151220ustar00rootroot00000000000000linters: enable: - gofmt - godot linters-settings: godot: # comments to be checked: `declarations`, `toplevel`, or `all` scope: declarations # check that each sentence starts with a capital letter capital: true go-gh-1.2.1/LICENSE000066400000000000000000000020541437723465500135420ustar00rootroot00000000000000MIT License Copyright (c) 2021 GitHub Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. go-gh-1.2.1/README.md000066400000000000000000000044561437723465500140240ustar00rootroot00000000000000# Go library for the GitHub CLI `go-gh` is a collection of Go modules to make authoring [GitHub CLI extensions][extensions] easier. 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. - 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. - 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. - The [`browser`](https://pkg.go.dev/github.com/cli/go-gh/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 ```golang package main import ( "fmt" "log" "github.com/cli/go-gh" ) func main() { // These examples assume `gh` is installed and has been authenticated // 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) if err != nil { log.Fatal(err) } response := []struct{ Name string }{} err = client.Get("repos/cli/cli/tags", &response) if err != nil { log.Fatal(err) } fmt.Println(response) } ``` See [examples][] for more demonstrations of usage. ## Contributing 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 go-gh-1.2.1/example_gh_test.go000066400000000000000000000165341437723465500162440ustar00rootroot00000000000000package gh_test import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "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" graphql "github.com/cli/shurcooL-graphql" ) // Execute 'gh issue list -R cli/cli', and print the output. func ExampleExec() { args := []string{"issue", "list", "-R", "cli/cli"} stdOut, stdErr, err := gh.Exec(args...) if err != nil { log.Fatal(err) } fmt.Println(stdOut.String()) fmt.Println(stdErr.String()) } // Get tags from cli/cli repository using REST API. func ExampleRESTClient_simple() { client, err := gh.RESTClient(nil) if err != nil { log.Fatal(err) } response := []struct{ Name string }{} err = client.Get("repos/cli/cli/tags", &response) if err != nil { log.Fatal(err) } fmt.Println(response) } // Get tags from cli/cli repository using REST API. // Specifying host, auth token, headers and logging to stdout. func ExampleRESTClient_advanced() { 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) if err != nil { log.Fatal(err) } response := []struct{ Name string }{} err = client.Get("repos/cli/cli/tags", &response) if err != nil { log.Fatal(err) } fmt.Println(response) } // Get release asset from cli/cli repository using REST API. func ExampleRESTClient_request() { opts := api.ClientOptions{ Headers: map[string]string{"Accept": "application/octet-stream"}, } client, err := gh.RESTClient(&opts) if err != nil { log.Fatal(err) } // URL to cli/cli release v2.14.2 checksums.txt assetURL := "repos/cli/cli/releases/assets/71589494" response, err := client.Request(http.MethodGet, assetURL, nil) if err != nil { log.Fatal(err) } defer response.Body.Close() f, err := os.CreateTemp("", "*_checksums.txt") if err != nil { log.Fatal(err) } defer f.Close() _, err = io.Copy(f, response.Body) if err != nil { log.Fatal(err) } fmt.Printf("Asset downloaded to %s\n", f.Name()) } // Get releases from cli/cli repository using REST API with paginated results. func ExampleRESTClient_pagination() { var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) findNextPage := func(response *http.Response) (string, bool) { for _, m := range linkRE.FindAllStringSubmatch(response.Header.Get("Link"), -1) { if len(m) > 2 && m[2] == "next" { return m[1], true } } return "", false } client, err := gh.RESTClient(nil) if err != nil { log.Fatal(err) } requestPath := "repos/cli/cli/releases" page := 1 for { response, err := client.Request(http.MethodGet, requestPath, nil) if err != nil { log.Fatal(err) } data := []struct{ Name string }{} decoder := json.NewDecoder(response.Body) err = decoder.Decode(&data) if err != nil { log.Fatal(err) } if err := response.Body.Close(); err != nil { log.Fatal(err) } fmt.Printf("Page: %d\n", page) fmt.Println(data) var hasNextPage bool if requestPath, hasNextPage = findNextPage(response); !hasNextPage { break } page++ } } // Query tags from cli/cli repository using GQL API. func ExampleGQLClient_simple() { client, err := gh.GQLClient(nil) if err != nil { log.Fatal(err) } var query struct { Repository struct { Refs struct { Nodes []struct { Name string } } `graphql:"refs(refPrefix: $refPrefix, last: $last)"` } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]interface{}{ "refPrefix": graphql.String("refs/tags/"), "last": graphql.Int(30), "owner": graphql.String("cli"), "name": graphql.String("cli"), } err = client.Query("RepositoryTags", &query, variables) if err != nil { log.Fatal(err) } fmt.Println(query) } // Query tags from cli/cli repository using GQL API. // Enable caching and request timeout. func ExampleGQLClient_advanced() { opts := api.ClientOptions{ EnableCache: true, Timeout: 5 * time.Second, } client, err := gh.GQLClient(&opts) if err != nil { log.Fatal(err) } var query struct { Repository struct { Refs struct { Nodes []struct { Name string } } `graphql:"refs(refPrefix: $refPrefix, last: $last)"` } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]interface{}{ "refPrefix": graphql.String("refs/tags/"), "last": graphql.Int(30), "owner": graphql.String("cli"), "name": graphql.String("cli"), } err = client.Query("RepositoryTags", &query, variables) if err != nil { log.Fatal(err) } 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) if err != nil { log.Fatal(err) } var mutation struct { AddStar struct { Starrable struct { Repository struct { StargazerCount int } `graphql:"... on Repository"` Gist struct { StargazerCount int } `graphql:"... on Gist"` } } `graphql:"addStar(input: $input)"` } // Note that the shurcooL/githubv4 package has defined input structs generated from the // GraphQL schema that can be used instead of writing your own. type AddStarInput struct { StarrableID string `json:"starrableId"` } variables := map[string]interface{}{ "input": AddStarInput{ StarrableID: "R_kgDOF_MgQQ", }, } err = client.Mutate("AddStar", &mutation, variables) if err != nil { log.Fatal(err) } 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) if err != nil { log.Fatal(err) } var query struct { Repository struct { Releases struct { Nodes []struct { Name string } PageInfo struct { HasNextPage bool EndCursor string } } `graphql:"releases(first: 30, after: $endCursor)"` } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]interface{}{ "owner": graphql.String("cli"), "name": graphql.String("cli"), "endCursor": (*graphql.String)(nil), } page := 1 for { if err := client.Query("RepositoryReleases", &query, variables); err != nil { log.Fatal(err) } fmt.Printf("Page: %d\n", page) fmt.Println(query.Repository.Releases.Nodes) if !query.Repository.Releases.PageInfo.HasNextPage { break } variables["endCursor"] = graphql.String(query.Repository.Releases.PageInfo.EndCursor) page++ } } // Get repository for the current directory. func ExampleCurrentRepository() { repo, err := gh.CurrentRepository() if err != nil { log.Fatal(err) } 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. func ExampleTablePrinter() { terminal := term.FromEnv() termWidth, _, _ := terminal.Size() t := tableprinter.New(terminal.Out(), terminal.IsTerminalOutput(), termWidth) red := func(s string) string { return "\x1b[31m" + s + "\x1b[m" } // add a field that will render with color and will not be auto-truncated t.AddField("1", tableprinter.WithColor(red), tableprinter.WithTruncate(nil)) t.AddField("hello") t.EndRow() t.AddField("2") t.AddField("world") t.EndRow() if err := t.Render(); err != nil { log.Fatal(err) } } go-gh-1.2.1/gh.go000066400000000000000000000120271437723465500134630ustar00rootroot00000000000000// Package gh is a library for CLI Go applications to help interface with the gh CLI tool, // and the GitHub API. // // Note that the examples in this package assume gh and git are installed. They do not run in // the Go Playground used by pkg.go.dev. package gh import ( "bytes" "errors" "fmt" "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" ) // Exec gh command with provided arguments. func Exec(args ...string) (stdOut, stdErr bytes.Buffer, err error) { path, err := path() if err != nil { err = fmt.Errorf("could not find gh executable in PATH. error: %w", err) return } return run(path, nil, args...) } func path() (string, error) { return safeexec.LookPath("gh") } func run(path string, env []string, args ...string) (stdOut, stdErr bytes.Buffer, err error) { cmd := exec.Command(path, args...) cmd.Stdout = &stdOut cmd.Stderr = &stdErr if env != nil { cmd.Env = env } err = cmd.Run() if err != nil { err = fmt.Errorf("failed to run gh: %s. error: %w", stdErr.String(), err) return } return } // 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 } go-gh-1.2.1/gh_test.go000066400000000000000000000164621437723465500145310ustar00rootroot00000000000000package gh import ( "fmt" "net/http" "os" "strings" "testing" "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) { if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" { return } if err := func(args []string) error { if args[len(args)-1] == "error" { return fmt.Errorf("process exited with error") } fmt.Fprintf(os.Stdout, "%v", args) return nil }(os.Args[3:]); err != nil { fmt.Fprint(os.Stderr, err) os.Exit(1) } os.Exit(0) } func TestRun(t *testing.T) { stdOut, stdErr, err := run(os.Args[0], []string{"GH_WANT_HELPER_PROCESS=1"}, "-test.run=TestHelperProcess", "--", "gh", "issue", "list") assert.NoError(t, err) assert.Equal(t, "[gh issue list]", stdOut.String()) assert.Equal(t, "", stdErr.String()) } func TestRunError(t *testing.T) { stdOut, stdErr, err := run(os.Args[0], []string{"GH_WANT_HELPER_PROCESS=1"}, "-test.run=TestHelperProcess", "--", "gh", "issue", "list", "error") assert.EqualError(t, err, "failed to run gh: process exited with error. error: exit status 1") assert.Equal(t, "", stdOut.String()) assert.Equal(t, "process exited with error", stdErr.String()) } 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 ` } go-gh-1.2.1/go.mod000066400000000000000000000033251437723465500136450ustar00rootroot00000000000000module github.com/cli/go-gh go 1.19 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/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/henvic/httpretty v0.0.6 github.com/itchyny/gojq v0.12.8 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.12.0 github.com/stretchr/testify v1.7.0 github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e golang.org/x/sys v0.5.0 golang.org/x/term v0.5.0 gopkg.in/h2non/gock.v1 v1.1.2 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/alecthomas/chroma v0.10.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.4.0 // indirect github.com/gorilla/css v1.0.0 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/itchyny/timefmt-go v0.1.3 // indirect github.com/kr/pretty v0.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect github.com/microcosm-cc/bluemonday v1.0.20 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/yuin/goldmark v1.4.4 // indirect github.com/yuin/goldmark-emoji v1.0.1 // indirect golang.org/x/net v0.7.0 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) go-gh-1.2.1/go.sum000066400000000000000000000235231437723465500136740ustar00rootroot00000000000000github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da h1:FGz53GWQRiKQ/5xUsoCCkewSQIC7u81Scaxx2nUy3nM= github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da/go.mod h1:HXz79SMFnF9arKxqeoHWxmo1BhplAH7wehlRhKQIL94= 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/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= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= 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/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo= github.com/itchyny/gojq v0.12.8 h1:Zxcwq8w4IeR8JJYEtoG2MWJZUv0RGY6QqJcO1cqV8+A= github.com/itchyny/gojq v0.12.8/go.mod h1:gE2kZ9fVRU0+JAksaTzjIlgnCa2akU+a1V0WXgJQN5c= github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU= github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE= github.com/microcosm-cc/bluemonday v1.0.20 h1:flpzsq4KU3QIYAYGV/szUat7H+GPOXR0B2JU5A1Wp8Y= github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= github.com/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc= github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8= github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs= github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0 h1:n2a8QNdAb0sZNpU9R1ALUXBbY+w51fCQDN+7EdxNBsY= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/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.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= go-gh-1.2.1/internal/000077500000000000000000000000001437723465500143505ustar00rootroot00000000000000go-gh-1.2.1/internal/api/000077500000000000000000000000001437723465500151215ustar00rootroot00000000000000go-gh-1.2.1/internal/api/cache.go000066400000000000000000000076711437723465500165260ustar00rootroot00000000000000package api import ( "bufio" "bytes" "crypto/sha256" "errors" "fmt" "io" "net/http" "os" "path/filepath" "strings" "sync" "time" ) type cache struct { dir string ttl time.Duration } type cacheRoundTripper struct { fs fileStorage rt http.RoundTripper } type fileStorage struct { dir string ttl time.Duration mu *sync.RWMutex } type readCloser struct { io.Reader io.Closer } 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 } 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 } func (c cache) RoundTripper(rt http.RoundTripper) http.RoundTripper { fs := fileStorage{ dir: c.dir, ttl: c.ttl, mu: &sync.RWMutex{}, } return cacheRoundTripper{fs: fs, rt: rt} } func (crt cacheRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { reqDir, reqTTL := requestCacheOptions(req) if crt.fs.ttl == 0 && reqTTL == 0 { return crt.rt.RoundTrip(req) } if !isCacheableRequest(req) { return crt.rt.RoundTrip(req) } origDir := crt.fs.dir if reqDir != "" { crt.fs.dir = reqDir } origTTL := crt.fs.ttl if reqTTL != 0 { crt.fs.ttl = reqTTL } key, keyErr := cacheKey(req) if keyErr == nil { if res, err := crt.fs.read(key); err == nil { res.Request = req return res, nil } } res, err := crt.rt.RoundTrip(req) if err == nil && keyErr == nil && isCacheableResponse(res) { _ = crt.fs.store(key, res) } crt.fs.dir = origDir crt.fs.ttl = origTTL return res, err } // Allow an individual request to override cache options. func requestCacheOptions(req *http.Request) (string, time.Duration) { var dur time.Duration dir := req.Header.Get("X-GH-CACHE-DIR") ttl := req.Header.Get("X-GH-CACHE-TTL") if ttl != "" { dur, _ = time.ParseDuration(ttl) } return dir, dur } 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 } 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, } } go-gh-1.2.1/internal/api/cache_test.go000066400000000000000000000124111437723465500175510ustar00rootroot00000000000000package api import ( "bytes" "fmt" "io" "net/http" "path/filepath" "testing" "time" "github.com/cli/go-gh/pkg/api" "github.com/stretchr/testify/assert" ) func TestCacheResponse(t *testing.T) { counter := 0 fakeHTTP := tripper{ 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( &api.ClientOptions{ Transport: fakeHTTP, EnableCache: true, CacheDir: cacheDir, LogIgnoreEnv: true, }) 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) assert.NoError(t, err) assert.Equal(t, "1: GET http://example.com/path", res) res, err = do("GET", "http://example.com/path", nil) assert.NoError(t, err) assert.Equal(t, "1: GET http://example.com/path", res) res, err = do("GET", "http://example.com/path2", nil) assert.NoError(t, err) assert.Equal(t, "2: GET http://example.com/path2", res) res, err = do("POST", "http://example.com/path2", nil) assert.NoError(t, err) assert.Equal(t, "3: POST http://example.com/path2", res) res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello`)) assert.NoError(t, err) assert.Equal(t, "4: POST http://example.com/graphql", res) res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello`)) assert.NoError(t, err) assert.Equal(t, "4: POST http://example.com/graphql", res) res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello2`)) assert.NoError(t, err) assert.Equal(t, "5: POST http://example.com/graphql", res) res, err = do("GET", "http://example.com/error", nil) assert.NoError(t, err) assert.Equal(t, "6: GET http://example.com/error", res) res, err = do("GET", "http://example.com/error", nil) assert.NoError(t, err) assert.Equal(t, "7: GET http://example.com/error", res) } func TestCacheResponseRequestCacheOptions(t *testing.T) { counter := 0 fakeHTTP := tripper{ 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( &api.ClientOptions{ Transport: fakeHTTP, EnableCache: false, CacheDir: cacheDir, LogIgnoreEnv: true, }) do := func(method, url string, body io.Reader) (string, error) { req, err := http.NewRequest(method, url, body) if err != nil { return "", err } req.Header.Set("X-GH-CACHE-DIR", cacheDir) req.Header.Set("X-GH-CACHE-TTL", "1h") 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) assert.NoError(t, err) assert.Equal(t, "1: GET http://example.com/path", res) res, err = do("GET", "http://example.com/path", nil) assert.NoError(t, err) assert.Equal(t, "1: GET http://example.com/path", res) res, err = do("GET", "http://example.com/path2", nil) assert.NoError(t, err) assert.Equal(t, "2: GET http://example.com/path2", res) res, err = do("POST", "http://example.com/path2", nil) assert.NoError(t, err) assert.Equal(t, "3: POST http://example.com/path2", res) res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello`)) assert.NoError(t, err) assert.Equal(t, "4: POST http://example.com/graphql", res) res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello`)) assert.NoError(t, err) assert.Equal(t, "4: POST http://example.com/graphql", res) res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello2`)) assert.NoError(t, err) assert.Equal(t, "5: POST http://example.com/graphql", res) res, err = do("GET", "http://example.com/error", nil) assert.NoError(t, err) assert.Equal(t, "6: GET http://example.com/error", res) res, err = do("GET", "http://example.com/error", nil) assert.NoError(t, err) assert.Equal(t, "7: GET http://example.com/error", res) } func TestRequestCacheOptions(t *testing.T) { req, err := http.NewRequest("GET", "some/url", nil) assert.NoError(t, err) req.Header.Set("X-GH-CACHE-DIR", "some/dir/path") req.Header.Set("X-GH-CACHE-TTL", "1h") dir, ttl := requestCacheOptions(req) assert.Equal(t, dir, "some/dir/path") assert.Equal(t, ttl, time.Hour) } go-gh-1.2.1/internal/api/gql_client.go000066400000000000000000000067151437723465500176020ustar00rootroot00000000000000package 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) } go-gh-1.2.1/internal/api/gql_client_test.go000066400000000000000000000121171437723465500206320ustar00rootroot00000000000000package api import ( "context" "testing" "time" "github.com/stretchr/testify/assert" "gopkg.in/h2non/gock.v1" ) func TestGQLClientDo(t *testing.T) { tests := []struct { name string host string httpMocks func() wantErr bool wantErrMsg string wantLogin string }{ { name: "success request", httpMocks: func() { gock.New("https://api.github.com"). Post("/graphql"). BodyString(`{"query":"QUERY","variables":{"var":"test"}}`). Reply(200). JSON(`{"data":{"viewer":{"login":"hubot"}}}`) }, wantLogin: "hubot", }, { name: "fail request", httpMocks: func() { gock.New("https://api.github.com"). Post("/graphql"). BodyString(`{"query":"QUERY","variables":{"var":"test"}}`). Reply(200). JSON(`{"errors":[{"message":"OH NO"},{"message":"this is fine"}]}`) }, wantErr: true, wantErrMsg: "GraphQL: OH NO, this is fine", }, { name: "http fail request empty response", httpMocks: func() { gock.New("https://api.github.com"). Post("/graphql"). BodyString(`{"query":"QUERY","variables":{"var":"test"}}`). Reply(404). JSON(`{}`) }, wantErr: true, wantErrMsg: "HTTP 404 (https://api.github.com/graphql)", }, { name: "http fail request message response", httpMocks: func() { gock.New("https://api.github.com"). Post("/graphql"). BodyString(`{"query":"QUERY","variables":{"var":"test"}}`). Reply(422). JSON(`{"message": "OH NO"}`) }, wantErr: true, wantErrMsg: "HTTP 422: OH NO (https://api.github.com/graphql)", }, { name: "http fail request errors response", httpMocks: func() { gock.New("https://api.github.com"). Post("/graphql"). BodyString(`{"query":"QUERY","variables":{"var":"test"}}`). Reply(502). JSON(`{"errors":[{"message":"Something went wrong"}]}`) }, wantErr: true, wantErrMsg: "HTTP 502: Something went wrong (https://api.github.com/graphql)", }, { name: "support enterprise hosts", host: "enterprise.com", httpMocks: func() { gock.New("https://enterprise.com"). Post("/api/graphql"). BodyString(`{"query":"QUERY","variables":{"var":"test"}}`). Reply(200). JSON(`{"data":{"viewer":{"login":"hubot"}}}`) }, wantLogin: "hubot", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Cleanup(gock.Off) if tt.host == "" { tt.host = "github.com" } if tt.httpMocks != nil { tt.httpMocks() } client := NewGQLClient(tt.host, nil) vars := map[string]interface{}{"var": "test"} res := struct{ Viewer struct{ Login string } }{} err := client.Do("QUERY", vars, &res) if tt.wantErr { assert.EqualError(t, err, tt.wantErrMsg) } else { assert.NoError(t, err) } assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) assert.Equal(t, tt.wantLogin, res.Viewer.Login) }) } } func TestGQLClientDoWithContext(t *testing.T) { tests := []struct { name string wantErrMsg string getCtx func() context.Context }{ { name: "http fail request canceled", getCtx: func() context.Context { ctx, cancel := context.WithCancel(context.Background()) // call 'cancel' to ensure that context is already canceled cancel() return ctx }, wantErrMsg: `Post "https://api.github.com/graphql": context canceled`, }, { name: "http fail request timed out", getCtx: func() context.Context { // pass current time to ensure that deadline has already passed ctx, cancel := context.WithDeadline(context.Background(), time.Now()) cancel() return ctx }, wantErrMsg: `Post "https://api.github.com/graphql": context deadline exceeded`, }, } 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"). BodyString(`{"query":"QUERY","variables":{"var":"test"}}`). Reply(200). JSON(`{}`) client := NewGQLClient("github.com", nil) 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) { tests := []struct { name string host string wantEndpoint string }{ { name: "github", host: "github.com", wantEndpoint: "https://api.github.com/graphql", }, { name: "localhost", host: "github.localhost", wantEndpoint: "http://api.github.localhost/graphql", }, { name: "garage", host: "garage.github.com", wantEndpoint: "https://garage.github.com/api/graphql", }, { name: "enterprise", host: "enterprise.com", wantEndpoint: "https://enterprise.com/api/graphql", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { endpoint := gqlEndpoint(tt.host) assert.Equal(t, tt.wantEndpoint, endpoint) }) } } go-gh-1.2.1/internal/api/http.go000066400000000000000000000126111437723465500164300ustar00rootroot00000000000000package api import ( "fmt" "net" "net/http" "os" "path/filepath" "regexp" "runtime/debug" "strings" "time" "github.com/cli/go-gh/pkg/api" "github.com/cli/go-gh/pkg/term" "github.com/henvic/httpretty" "github.com/thlib/go-timezone-local/tzlocal" ) const ( accept = "Accept" authorization = "Authorization" contentType = "Content-Type" github = "github.com" jsonContentType = "application/json; charset=utf-8" localhost = "github.localhost" modulePath = "github.com/cli/go-gh" timeZone = "Time-Zone" userAgent = "User-Agent" ) var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`) func NewHTTPClient(opts *api.ClientOptions) http.Client { if opts == nil { opts = &api.ClientOptions{} } transport := http.DefaultTransport if opts.UnixDomainSocket != "" { transport = newUnixDomainSocketRoundTripper(opts.UnixDomainSocket) } if opts.Transport != nil { transport = opts.Transport } if opts.CacheDir == "" { opts.CacheDir = filepath.Join(os.TempDir(), "gh-cli-cache") } if opts.EnableCache && opts.CacheTTL == 0 { opts.CacheTTL = time.Hour * 24 } c := cache{dir: opts.CacheDir, ttl: opts.CacheTTL} transport = c.RoundTripper(transport) if opts.Log == nil && !opts.LogIgnoreEnv { ghDebug := os.Getenv("GH_DEBUG") switch ghDebug { case "", "0", "false", "no": // no logging default: opts.Log = os.Stderr opts.LogColorize = !term.IsColorDisabled() && term.IsTerminal(os.Stderr) opts.LogVerboseHTTP = strings.Contains(ghDebug, "api") } } if opts.Log != nil { logger := &httpretty.Logger{ Time: true, TLS: false, Colors: opts.LogColorize, RequestHeader: opts.LogVerboseHTTP, RequestBody: opts.LogVerboseHTTP, ResponseHeader: opts.LogVerboseHTTP, ResponseBody: opts.LogVerboseHTTP, Formatters: []httpretty.Formatter{&jsonFormatter{colorize: opts.LogColorize}}, MaxResponseBody: 100000, } logger.SetOutput(opts.Log) logger.SetBodyFilter(func(h http.Header) (skip bool, err error) { return !inspectableMIMEType(h.Get(contentType)), nil }) transport = logger.RoundTripper(transport) } if opts.Headers == nil { opts.Headers = map[string]string{} } if !opts.SkipDefaultHeaders { resolveHeaders(opts.Headers) } transport = newHeaderRoundTripper(opts.Host, opts.AuthToken, opts.Headers, transport) return http.Client{Transport: transport, Timeout: opts.Timeout} } func inspectableMIMEType(t string) bool { return strings.HasPrefix(t, "text/") || strings.HasPrefix(t, "application/x-www-form-urlencoded") || jsonTypeRE.MatchString(t) } func isSameDomain(requestHost, domain string) bool { requestHost = strings.ToLower(requestHost) domain = strings.ToLower(domain) return (requestHost == domain) || strings.HasSuffix(requestHost, "."+domain) } func isGarage(host string) bool { return strings.EqualFold(host, "garage.github.com") } func isEnterprise(host string) bool { return host != github && host != localhost } func normalizeHostname(hostname string) string { hostname = strings.ToLower(hostname) if strings.HasSuffix(hostname, "."+github) { return github } if strings.HasSuffix(hostname, "."+localhost) { return localhost } return hostname } type headerRoundTripper struct { headers map[string]string host string rt http.RoundTripper } func resolveHeaders(headers map[string]string) { if _, ok := headers[contentType]; !ok { headers[contentType] = jsonContentType } if _, ok := headers[userAgent]; !ok { headers[userAgent] = "go-gh" info, ok := debug.ReadBuildInfo() if ok { for _, dep := range info.Deps { if dep.Path == modulePath { headers[userAgent] += fmt.Sprintf(" %s", dep.Version) break } } } } if _, ok := headers[timeZone]; !ok { tz := currentTimeZone() if tz != "" { headers[timeZone] = tz } } if _, ok := headers[accept]; !ok { // Preview for PullRequest.mergeStateStatus. a := "application/vnd.github.merge-info-preview+json" // Preview for visibility when RESTing repos into an org. a += ", application/vnd.github.nebula-preview" headers[accept] = a } } func newHeaderRoundTripper(host string, authToken string, headers map[string]string, rt http.RoundTripper) http.RoundTripper { if _, ok := headers[authorization]; !ok && authToken != "" { headers[authorization] = fmt.Sprintf("token %s", authToken) } if len(headers) == 0 { return rt } return headerRoundTripper{host: host, headers: headers, rt: rt} } func (hrt headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { for k, v := range hrt.headers { // If the authorization header has been set and the request // host is not in the same domain that was specified in the ClientOptions // then do not add the authorization header to the request. if k == authorization && !isSameDomain(req.URL.Hostname(), hrt.host) { continue } // If the header is already set in the request, don't overwrite it. if req.Header.Get(k) == "" { req.Header.Set(k, v) } } return hrt.rt.RoundTrip(req) } func newUnixDomainSocketRoundTripper(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, } } func currentTimeZone() string { tz, err := tzlocal.RuntimeTZ() if err != nil { return "" } return tz } go-gh-1.2.1/internal/api/http_test.go000066400000000000000000000113271437723465500174720ustar00rootroot00000000000000package api import ( "bytes" "fmt" "io" "net/http" "testing" "github.com/cli/go-gh/pkg/api" "github.com/stretchr/testify/assert" ) func TestNewHTTPClient(t *testing.T) { reflectHTTP := tripper{ roundTrip: func(req *http.Request) (*http.Response, error) { header := req.Header.Clone() body := "{}" return &http.Response{ StatusCode: 200, Header: header, Body: io.NopCloser(bytes.NewBufferString(body)), }, nil }, } tests := []struct { name string enableLog bool log *bytes.Buffer host string headers map[string]string skipHeaders bool wantHeaders http.Header }{ { name: "sets default headers", wantHeaders: defaultHeaders(), }, { name: "allows overriding default headers", headers: map[string]string{ authorization: "token new_token", accept: "application/vnd.github.test-preview", }, wantHeaders: func() http.Header { h := defaultHeaders() h.Set(authorization, "token new_token") h.Set(accept, "application/vnd.github.test-preview") return h }(), }, { name: "allows setting custom headers", headers: map[string]string{ "custom": "testing", }, wantHeaders: func() http.Header { h := defaultHeaders() h.Set("custom", "testing") return h }(), }, { name: "allows setting logger", enableLog: true, log: &bytes.Buffer{}, wantHeaders: defaultHeaders(), }, { name: "does not add an authorization header for non-matching host", host: "notauthorized.com", wantHeaders: func() http.Header { h := defaultHeaders() h.Del(authorization) return h }(), }, { name: "does not add an authorization header for non-matching host subdomain", host: "test.company", wantHeaders: func() http.Header { h := defaultHeaders() h.Del(authorization) return h }(), }, { name: "adds an authorization header for a matching host", host: "test.com", wantHeaders: defaultHeaders(), }, { name: "adds an authorization header if hosts match but differ in case", host: "TeSt.CoM", wantHeaders: defaultHeaders(), }, { name: "skips default headers", skipHeaders: true, wantHeaders: func() http.Header { h := defaultHeaders() h.Del(accept) h.Del(contentType) h.Del(timeZone) h.Del(userAgent) return h }(), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.host == "" { tt.host = "test.com" } opts := api.ClientOptions{ Host: tt.host, AuthToken: "oauth_token", Headers: tt.headers, SkipDefaultHeaders: tt.skipHeaders, Transport: reflectHTTP, LogIgnoreEnv: true, } if tt.enableLog { opts.Log = tt.log } client := NewHTTPClient(&opts) res, err := client.Get("https://test.com") assert.NoError(t, err) assert.Equal(t, tt.wantHeaders, res.Header) if tt.enableLog { assert.NotEmpty(t, tt.log) } }) } } func TestIsEnterprise(t *testing.T) { tests := []struct { name string host string wantOut bool }{ { name: "github", host: "github.com", wantOut: false, }, { name: "localhost", host: "github.localhost", wantOut: false, }, { name: "enterprise", host: "mygithub.com", wantOut: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { out := isEnterprise(tt.host) assert.Equal(t, tt.wantOut, out) }) } } func TestNormalizeHostname(t *testing.T) { tests := []struct { name string host string wantHost string }{ { name: "github domain", host: "test.github.com", wantHost: "github.com", }, { name: "capitalized", host: "GitHub.com", wantHost: "github.com", }, { name: "localhost domain", host: "test.github.localhost", wantHost: "github.localhost", }, { name: "enterprise domain", host: "mygithub.com", wantHost: "mygithub.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { normalized := normalizeHostname(tt.host) assert.Equal(t, tt.wantHost, normalized) }) } } type tripper struct { roundTrip func(*http.Request) (*http.Response, error) } func (tr tripper) RoundTrip(req *http.Request) (*http.Response, error) { return tr.roundTrip(req) } func defaultHeaders() http.Header { h := http.Header{} a := "application/vnd.github.merge-info-preview+json" a += ", application/vnd.github.nebula-preview" h.Set(contentType, jsonContentType) h.Set(userAgent, "go-gh") h.Set(authorization, fmt.Sprintf("token %s", "oauth_token")) h.Set(timeZone, currentTimeZone()) h.Set(accept, a) return h } go-gh-1.2.1/internal/api/log_formatter.go000066400000000000000000000025341437723465500203200ustar00rootroot00000000000000package api import ( "bytes" "encoding/json" "fmt" "io" "strings" "github.com/cli/go-gh/pkg/jsonpretty" ) type graphqlBody struct { Query string `json:"query"` OperationName string `json:"operationName"` Variables json.RawMessage `json:"variables"` } // jsonFormatter is a httpretty.Formatter that prettifies JSON payloads and GraphQL queries. type jsonFormatter struct { colorize bool } func (f *jsonFormatter) Format(w io.Writer, src []byte) error { var graphqlQuery graphqlBody // TODO: find more precise way to detect a GraphQL query from the JSON payload alone if err := json.Unmarshal(src, &graphqlQuery); err == nil && graphqlQuery.Query != "" && len(graphqlQuery.Variables) > 0 { colorHighlight := "\x1b[35;1m" colorReset := "\x1b[m" if !f.colorize { colorHighlight = "" colorReset = "" } if _, err := fmt.Fprintf(w, "%sGraphQL query:%s\n%s\n", colorHighlight, colorReset, strings.ReplaceAll(strings.TrimSpace(graphqlQuery.Query), "\t", " ")); err != nil { return err } if _, err := fmt.Fprintf(w, "%sGraphQL variables:%s %s\n", colorHighlight, colorReset, string(graphqlQuery.Variables)); err != nil { return err } return nil } return jsonpretty.Format(w, bytes.NewReader(src), " ", f.colorize) } func (f *jsonFormatter) Match(t string) bool { return jsonTypeRE.MatchString(t) } go-gh-1.2.1/internal/api/rest_client.go000066400000000000000000000061211437723465500177630ustar00rootroot00000000000000package 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) } go-gh-1.2.1/internal/api/rest_client_test.go000066400000000000000000000244041437723465500210260ustar00rootroot00000000000000package api import ( "bytes" "context" "fmt" "io" "net/http" "strings" "testing" "time" "github.com/stretchr/testify/assert" "gopkg.in/h2non/gock.v1" ) func TestRESTClientRequest(t *testing.T) { tests := []struct { name string host string path string httpMocks func() wantErr bool wantErrMsg string wantBody string }{ { name: "success request empty response", path: "some/test/path", httpMocks: func() { gock.New("https://api.github.com"). Get("/some/test/path"). Reply(204) }, wantBody: ``, }, { name: "success request non-empty response", path: "some/test/path", httpMocks: func() { gock.New("https://api.github.com"). Get("/some/test/path"). Reply(200). JSON(`{"message": "success"}`) }, wantBody: `{"message": "success"}`, }, { name: "fail request empty response", path: "some/test/path", httpMocks: func() { gock.New("https://api.github.com"). Get("/some/test/path"). Reply(404). JSON(`{}`) }, wantErr: true, wantErrMsg: "HTTP 404 (https://api.github.com/some/test/path)", wantBody: `{}`, }, { name: "fail request non-empty response", path: "some/test/path", httpMocks: func() { gock.New("https://api.github.com"). Get("/some/test/path"). Reply(422). JSON(`{"message": "OH NO"}`) }, wantErr: true, wantErrMsg: "HTTP 422: OH NO (https://api.github.com/some/test/path)", wantBody: `{"message": "OH NO"}`, }, { name: "support full urls", path: "https://example.com/someother/test/path", httpMocks: func() { gock.New("https://example.com"). Get("/someother/test/path"). Reply(200). JSON(`{}`) }, wantBody: `{}`, }, { name: "support enterprise hosts", host: "enterprise.com", path: "some/test/path", httpMocks: func() { gock.New("https://enterprise.com"). Get("/some/test/path"). Reply(200). JSON(`{}`) }, wantBody: `{}`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Cleanup(gock.Off) if tt.host == "" { tt.host = "github.com" } if tt.httpMocks != nil { tt.httpMocks() } client := NewRESTClient(tt.host, nil) resp, err := client.Request("GET", tt.path, nil) if tt.wantErr { assert.EqualError(t, err, tt.wantErrMsg) } else { assert.NoError(t, err) } if err == nil { defer resp.Body.Close() body, err := io.ReadAll(resp.Body) assert.NoError(t, err) assert.Equal(t, tt.wantBody, string(body)) } assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) }) } } func TestRESTClientDo(t *testing.T) { tests := []struct { name string host string path string httpMocks func() wantErr bool wantErrMsg string wantMsg string }{ { name: "success request empty response", path: "some/test/path", httpMocks: func() { gock.New("https://api.github.com"). Get("/some/test/path"). Reply(204). JSON(`{}`) }, }, { name: "success request non-empty response", path: "some/test/path", httpMocks: func() { gock.New("https://api.github.com"). Get("/some/test/path"). Reply(200). JSON(`{"message": "success"}`) }, wantMsg: "success", }, { name: "fail request empty response", path: "some/test/path", httpMocks: func() { gock.New("https://api.github.com"). Get("/some/test/path"). Reply(404). JSON(`{}`) }, wantErr: true, wantErrMsg: "HTTP 404 (https://api.github.com/some/test/path)", }, { name: "fail request non-empty response", path: "some/test/path", httpMocks: func() { gock.New("https://api.github.com"). Get("/some/test/path"). Reply(422). JSON(`{"message": "OH NO"}`) }, wantErr: true, wantErrMsg: "HTTP 422: OH NO (https://api.github.com/some/test/path)", }, { name: "support full urls", path: "https://example.com/someother/test/path", httpMocks: func() { gock.New("https://example.com"). Get("/someother/test/path"). Reply(204). JSON(`{}`) }, }, { name: "support enterprise hosts", host: "enterprise.com", path: "some/test/path", httpMocks: func() { gock.New("https://enterprise.com"). Get("/some/test/path"). Reply(204). JSON(`{}`) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Cleanup(gock.Off) if tt.host == "" { tt.host = "github.com" } if tt.httpMocks != nil { tt.httpMocks() } client := NewRESTClient(tt.host, nil) res := struct{ Message string }{} err := client.Do("GET", tt.path, nil, &res) if tt.wantErr { assert.EqualError(t, err, tt.wantErrMsg) } else { assert.NoError(t, err) } assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) assert.Equal(t, tt.wantMsg, res.Message) }) } } func TestRESTClientDelete(t *testing.T) { t.Cleanup(gock.Off) gock.New("https://api.github.com"). Delete("/some/path/here"). Reply(204). JSON(`{}`) client := NewRESTClient("github.com", nil) err := client.Delete("some/path/here", nil) assert.NoError(t, err) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) } func TestRESTClientGet(t *testing.T) { t.Cleanup(gock.Off) gock.New("https://api.github.com"). Get("/some/path/here"). Reply(204). JSON(`{}`) client := NewRESTClient("github.com", nil) err := client.Get("some/path/here", nil) assert.NoError(t, err) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) } func TestRESTClientPatch(t *testing.T) { t.Cleanup(gock.Off) gock.New("https://api.github.com"). Patch("/some/path/here"). BodyString(`{}`). Reply(204). JSON(`{}`) client := NewRESTClient("github.com", nil) r := bytes.NewReader([]byte(`{}`)) err := client.Patch("some/path/here", r, nil) assert.NoError(t, err) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) } func TestRESTClientPost(t *testing.T) { t.Cleanup(gock.Off) gock.New("https://api.github.com"). Post("/some/path/here"). BodyString(`{}`). Reply(204). JSON(`{}`) client := NewRESTClient("github.com", nil) r := bytes.NewReader([]byte(`{}`)) err := client.Post("some/path/here", r, nil) assert.NoError(t, err) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) } func TestRESTClientPut(t *testing.T) { t.Cleanup(gock.Off) gock.New("https://api.github.com"). Put("/some/path/here"). BodyString(`{}`). Reply(204). JSON(`{}`) client := NewRESTClient("github.com", nil) r := bytes.NewReader([]byte(`{}`)) err := client.Put("some/path/here", r, nil) assert.NoError(t, err) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) } func TestRESTClientDoWithContext(t *testing.T) { tests := []struct { name string wantErrMsg string getCtx func() context.Context }{ { name: "http fail request canceled", getCtx: func() context.Context { ctx, cancel := context.WithCancel(context.Background()) // call 'cancel' to ensure that context is already canceled cancel() return ctx }, wantErrMsg: `Get "https://api.github.com/some/path": context canceled`, }, { name: "http fail request timed out", getCtx: func() context.Context { // pass current time to ensure that deadline has already passed ctx, cancel := context.WithDeadline(context.Background(), time.Now()) cancel() return ctx }, wantErrMsg: `Get "https://api.github.com/some/path": context deadline exceeded`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // given t.Cleanup(gock.Off) gock.New("https://api.github.com"). Get("/some/path"). Reply(204). JSON(`{}`) client := NewRESTClient("github.com", nil) res := struct{ Message string }{} // when ctx := tt.getCtx() gotErr := client.DoWithContext(ctx, http.MethodGet, "some/path", nil, &res) // then assert.EqualError(t, gotErr, tt.wantErrMsg) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) }) } } func TestRESTClientRequestWithContext(t *testing.T) { tests := []struct { name string wantErrMsg string getCtx func() context.Context }{ { name: "http fail request canceled", getCtx: func() context.Context { ctx, cancel := context.WithCancel(context.Background()) // call 'cancel' to ensure that context is already canceled cancel() return ctx }, wantErrMsg: `Get "https://api.github.com/some/path": context canceled`, }, { name: "http fail request timed out", getCtx: func() context.Context { // pass current time to ensure that deadline has already passed ctx, cancel := context.WithDeadline(context.Background(), time.Now()) cancel() return ctx }, wantErrMsg: `Get "https://api.github.com/some/path": context deadline exceeded`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // given t.Cleanup(gock.Off) gock.New("https://api.github.com"). Get("/some/path"). Reply(204). JSON(`{}`) client := NewRESTClient("github.com", nil) // when ctx := tt.getCtx() _, gotErr := client.RequestWithContext(ctx, http.MethodGet, "some/path", nil) // then assert.EqualError(t, gotErr, tt.wantErrMsg) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) }) } } func TestRestPrefix(t *testing.T) { tests := []struct { name string host string wantEndpoint string }{ { name: "github", host: "github.com", wantEndpoint: "https://api.github.com/", }, { name: "localhost", host: "github.localhost", wantEndpoint: "http://api.github.localhost/", }, { name: "garage", host: "garage.github.com", wantEndpoint: "https://garage.github.com/api/v3/", }, { name: "enterprise", host: "enterprise.com", wantEndpoint: "https://enterprise.com/api/v3/", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { endpoint := restPrefix(tt.host) assert.Equal(t, tt.wantEndpoint, endpoint) }) } } 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, ", ")) } go-gh-1.2.1/internal/git/000077500000000000000000000000001437723465500151335ustar00rootroot00000000000000go-gh-1.2.1/internal/git/git.go000066400000000000000000000013201437723465500162410ustar00rootroot00000000000000package git import ( "bytes" "fmt" "os/exec" "github.com/cli/safeexec" ) func Exec(args ...string) (stdOut, stdErr bytes.Buffer, err error) { path, err := path() if err != nil { err = fmt.Errorf("could not find git executable in PATH. error: %w", err) return } return run(path, nil, args...) } func path() (string, error) { return safeexec.LookPath("git") } func run(path string, env []string, args ...string) (stdOut, stdErr bytes.Buffer, err error) { cmd := exec.Command(path, args...) cmd.Stdout = &stdOut cmd.Stderr = &stdErr if env != nil { cmd.Env = env } err = cmd.Run() if err != nil { err = fmt.Errorf("failed to run git: %s. error: %w", stdErr.String(), err) return } return } go-gh-1.2.1/internal/git/git_test.go000066400000000000000000000021341437723465500173040ustar00rootroot00000000000000package git import ( "fmt" "os" "testing" "github.com/stretchr/testify/assert" ) func TestHelperProcess(t *testing.T) { if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" { return } if err := func(args []string) error { if args[len(args)-1] == "error" { return fmt.Errorf("process exited with error") } fmt.Fprintf(os.Stdout, "%v", args) return nil }(os.Args[3:]); err != nil { fmt.Fprint(os.Stderr, err) os.Exit(1) } os.Exit(0) } func TestRun(t *testing.T) { stdOut, stdErr, err := run(os.Args[0], []string{"GH_WANT_HELPER_PROCESS=1"}, "-test.run=TestHelperProcess", "--", "git", "status") assert.NoError(t, err) assert.Equal(t, "[git status]", stdOut.String()) assert.Equal(t, "", stdErr.String()) } func TestRunError(t *testing.T) { stdOut, stdErr, err := run(os.Args[0], []string{"GH_WANT_HELPER_PROCESS=1"}, "-test.run=TestHelperProcess", "--", "git", "status", "error") assert.EqualError(t, err, "failed to run git: process exited with error. error: exit status 1") assert.Equal(t, "", stdOut.String()) assert.Equal(t, "process exited with error", stdErr.String()) } go-gh-1.2.1/internal/git/remote.go000066400000000000000000000056351437723465500167660ustar00rootroot00000000000000package git import ( "net/url" "regexp" "sort" "strings" ) var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`) type RemoteSet []*Remote type Remote struct { Name string FetchURL *url.URL PushURL *url.URL Resolved string Host string Owner string Repo string } func (r RemoteSet) Len() int { return len(r) } func (r RemoteSet) Swap(i, j int) { r[i], r[j] = r[j], r[i] } func (r RemoteSet) Less(i, j int) bool { return remoteNameSortScore(r[i].Name) > remoteNameSortScore(r[j].Name) } func remoteNameSortScore(name string) int { switch strings.ToLower(name) { case "upstream": return 3 case "github": return 2 case "origin": return 1 default: return 0 } } func Remotes() (RemoteSet, error) { list, err := listRemotes() if err != nil { return nil, err } remotes := parseRemotes(list) setResolvedRemotes(remotes) sort.Sort(remotes) return remotes, nil } // Filter remotes by given hostnames, maintains original order. func (rs RemoteSet) FilterByHosts(hosts []string) RemoteSet { filtered := make(RemoteSet, 0) for _, remote := range rs { for _, host := range hosts { if strings.EqualFold(remote.Host, host) { filtered = append(filtered, remote) break } } } return filtered } func listRemotes() ([]string, error) { stdOut, _, err := Exec("remote", "-v") if err != nil { return nil, err } return toLines(stdOut.String()), nil } func parseRemotes(gitRemotes []string) RemoteSet { remotes := RemoteSet{} for _, r := range gitRemotes { match := remoteRE.FindStringSubmatch(r) if match == nil { continue } name := strings.TrimSpace(match[1]) urlStr := strings.TrimSpace(match[2]) urlType := strings.TrimSpace(match[3]) url, err := ParseURL(urlStr) if err != nil { continue } host, owner, repo, _ := RepoInfoFromURL(url) var rem *Remote if len(remotes) > 0 { rem = remotes[len(remotes)-1] if name != rem.Name { rem = nil } } if rem == nil { rem = &Remote{Name: name} remotes = append(remotes, rem) } switch urlType { case "fetch": rem.FetchURL = url rem.Host = host rem.Owner = owner rem.Repo = repo case "push": rem.PushURL = url if rem.Host == "" { rem.Host = host } if rem.Owner == "" { rem.Owner = owner } if rem.Repo == "" { rem.Repo = repo } } } return remotes } func setResolvedRemotes(remotes RemoteSet) { stdOut, _, err := Exec("config", "--get-regexp", `^remote\..*\.gh-resolved$`) if err != nil { return } for _, l := range toLines(stdOut.String()) { parts := strings.SplitN(l, " ", 2) if len(parts) < 2 { continue } rp := strings.SplitN(parts[0], ".", 3) if len(rp) < 2 { continue } name := rp[1] for _, r := range remotes { if r.Name == name { r.Resolved = parts[1] break } } } } func toLines(output string) []string { lines := strings.TrimSuffix(output, "\n") return strings.Split(lines, "\n") } go-gh-1.2.1/internal/git/remote_test.go000066400000000000000000000061651437723465500200240ustar00rootroot00000000000000package git import ( "os" "path/filepath" "testing" "github.com/stretchr/testify/assert" ) func TestRemotes(t *testing.T) { tempDir := t.TempDir() oldWd, _ := os.Getwd() assert.NoError(t, os.Chdir(tempDir)) t.Cleanup(func() { _ = os.Chdir(oldWd) }) _, _, err := Exec("init", "--quiet") assert.NoError(t, err) gitDir := filepath.Join(tempDir, ".git") remoteFile := filepath.Join(gitDir, "config") remotes := ` [remote "origin"] url = git@example.com:monalisa/origin.git [remote "test"] url = git://github.com/hubot/test.git gh-resolved = other [remote "upstream"] url = https://github.com/monalisa/upstream.git gh-resolved = base [remote "github"] url = git@github.com:hubot/github.git ` f, err := os.OpenFile(remoteFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0755) assert.NoError(t, err) _, err = f.Write([]byte(remotes)) assert.NoError(t, err) err = f.Close() assert.NoError(t, err) rs, err := Remotes() assert.NoError(t, err) assert.Equal(t, 4, len(rs)) assert.Equal(t, "upstream", rs[0].Name) assert.Equal(t, "base", rs[0].Resolved) assert.Equal(t, "github", rs[1].Name) assert.Equal(t, "", rs[1].Resolved) assert.Equal(t, "origin", rs[2].Name) assert.Equal(t, "", rs[2].Resolved) assert.Equal(t, "test", rs[3].Name) assert.Equal(t, "other", rs[3].Resolved) } func TestParseRemotes(t *testing.T) { remoteList := []string{ "mona\tgit@github.com:monalisa/myfork.git (fetch)", "origin\thttps://github.com/monalisa/octo-cat.git (fetch)", "origin\thttps://github.com/monalisa/octo-cat-push.git (push)", "upstream\thttps://example.com/nowhere.git (fetch)", "upstream\thttps://github.com/hubot/tools (push)", "zardoz\thttps://example.com/zed.git (push)", "koke\tgit://github.com/koke/grit.git (fetch)", "koke\tgit://github.com/koke/grit.git (push)", } r := parseRemotes(remoteList) assert.Equal(t, 5, len(r)) assert.Equal(t, "mona", r[0].Name) assert.Equal(t, "ssh://git@github.com/monalisa/myfork.git", r[0].FetchURL.String()) assert.Nil(t, r[0].PushURL) assert.Equal(t, "github.com", r[0].Host) assert.Equal(t, "monalisa", r[0].Owner) assert.Equal(t, "myfork", r[0].Repo) assert.Equal(t, "origin", r[1].Name) assert.Equal(t, "/monalisa/octo-cat.git", r[1].FetchURL.Path) assert.Equal(t, "/monalisa/octo-cat-push.git", r[1].PushURL.Path) assert.Equal(t, "github.com", r[1].Host) assert.Equal(t, "monalisa", r[1].Owner) assert.Equal(t, "octo-cat", r[1].Repo) assert.Equal(t, "upstream", r[2].Name) assert.Equal(t, "example.com", r[2].FetchURL.Host) assert.Equal(t, "github.com", r[2].PushURL.Host) assert.Equal(t, "github.com", r[2].Host) assert.Equal(t, "hubot", r[2].Owner) assert.Equal(t, "tools", r[2].Repo) assert.Equal(t, "zardoz", r[3].Name) assert.Nil(t, r[3].FetchURL) assert.Equal(t, "https://example.com/zed.git", r[3].PushURL.String()) assert.Equal(t, "", r[3].Host) assert.Equal(t, "", r[3].Owner) assert.Equal(t, "", r[3].Repo) assert.Equal(t, "koke", r[4].Name) assert.Equal(t, "/koke/grit.git", r[4].FetchURL.Path) assert.Equal(t, "/koke/grit.git", r[4].PushURL.Path) assert.Equal(t, "github.com", r[4].Host) assert.Equal(t, "koke", r[4].Owner) assert.Equal(t, "grit", r[4].Repo) } go-gh-1.2.1/internal/git/url.go000066400000000000000000000035411437723465500162670ustar00rootroot00000000000000package git import ( "fmt" "net/url" "strings" ) func IsURL(u string) bool { return strings.HasPrefix(u, "git@") || isSupportedProtocol(u) } func isSupportedProtocol(u string) bool { return strings.HasPrefix(u, "ssh:") || strings.HasPrefix(u, "git+ssh:") || strings.HasPrefix(u, "git:") || strings.HasPrefix(u, "http:") || strings.HasPrefix(u, "git+https:") || strings.HasPrefix(u, "https:") } func isPossibleProtocol(u string) bool { return isSupportedProtocol(u) || strings.HasPrefix(u, "ftp:") || strings.HasPrefix(u, "ftps:") || strings.HasPrefix(u, "file:") } // ParseURL normalizes git remote urls. func ParseURL(rawURL string) (u *url.URL, err error) { if !isPossibleProtocol(rawURL) && strings.ContainsRune(rawURL, ':') && // Not a Windows path. !strings.ContainsRune(rawURL, '\\') { // Support scp-like syntax for ssh protocol. rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1) } u, err = url.Parse(rawURL) if err != nil { return } if u.Scheme == "git+ssh" { u.Scheme = "ssh" } if u.Scheme == "git+https" { u.Scheme = "https" } if u.Scheme != "ssh" { return } if strings.HasPrefix(u.Path, "//") { u.Path = strings.TrimPrefix(u.Path, "/") } if idx := strings.Index(u.Host, ":"); idx >= 0 { u.Host = u.Host[0:idx] } return } // Extract GitHub repository information from a git remote URL. func RepoInfoFromURL(u *url.URL) (host string, owner string, name string, err error) { if u.Hostname() == "" { return "", "", "", fmt.Errorf("no hostname detected") } parts := strings.SplitN(strings.Trim(u.Path, "/"), "/", 3) if len(parts) != 2 { return "", "", "", fmt.Errorf("invalid path: %s", u.Path) } return normalizeHostname(u.Hostname()), parts[0], strings.TrimSuffix(parts[1], ".git"), nil } func normalizeHostname(h string) string { return strings.ToLower(strings.TrimPrefix(h, "www.")) } go-gh-1.2.1/internal/git/url_test.go000066400000000000000000000144501437723465500173270ustar00rootroot00000000000000package git import ( "net/url" "testing" "github.com/stretchr/testify/assert" ) func TestIsURL(t *testing.T) { tests := []struct { name string url string want bool }{ { name: "scp-like", url: "git@example.com:owner/repo", want: true, }, { name: "scp-like with no user", url: "example.com:owner/repo", want: false, }, { name: "ssh", url: "ssh://git@example.com/owner/repo", want: true, }, { name: "git", url: "git://example.com/owner/repo", want: true, }, { name: "git with extension", url: "git://example.com/owner/repo.git", want: true, }, { name: "git+ssh", url: "git+ssh://git@example.com/owner/repo.git", want: true, }, { name: "git+https", url: "git+https://example.com/owner/repo.git", want: true, }, { name: "http", url: "http://example.com/owner/repo.git", want: true, }, { name: "https", url: "https://example.com/owner/repo.git", want: true, }, { name: "no protocol", url: "example.com/owner/repo", want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.want, IsURL(tt.url)) }) } } func TestParseURL(t *testing.T) { type url struct { Scheme string User string Host string Path string } tests := []struct { name string url string want url wantErr bool }{ { name: "HTTPS", url: "https://example.com/owner/repo.git", want: url{ Scheme: "https", User: "", Host: "example.com", Path: "/owner/repo.git", }, }, { name: "HTTP", url: "http://example.com/owner/repo.git", want: url{ Scheme: "http", User: "", Host: "example.com", Path: "/owner/repo.git", }, }, { name: "git", url: "git://example.com/owner/repo.git", want: url{ Scheme: "git", User: "", Host: "example.com", Path: "/owner/repo.git", }, }, { name: "ssh", url: "ssh://git@example.com/owner/repo.git", want: url{ Scheme: "ssh", User: "git", Host: "example.com", Path: "/owner/repo.git", }, }, { name: "ssh with port", url: "ssh://git@example.com:443/owner/repo.git", want: url{ Scheme: "ssh", User: "git", Host: "example.com", Path: "/owner/repo.git", }, }, { name: "git+ssh", url: "git+ssh://example.com/owner/repo.git", want: url{ Scheme: "ssh", User: "", Host: "example.com", Path: "/owner/repo.git", }, }, { name: "git+https", url: "git+https://example.com/owner/repo.git", want: url{ Scheme: "https", User: "", Host: "example.com", Path: "/owner/repo.git", }, }, { name: "scp-like", url: "git@example.com:owner/repo.git", want: url{ Scheme: "ssh", User: "git", Host: "example.com", Path: "/owner/repo.git", }, }, { name: "scp-like, leading slash", url: "git@example.com:/owner/repo.git", want: url{ Scheme: "ssh", User: "git", Host: "example.com", Path: "/owner/repo.git", }, }, { name: "file protocol", url: "file:///example.com/owner/repo.git", want: url{ Scheme: "file", User: "", Host: "", Path: "/example.com/owner/repo.git", }, }, { name: "file path", url: "/example.com/owner/repo.git", want: url{ Scheme: "", User: "", Host: "", Path: "/example.com/owner/repo.git", }, }, { name: "Windows file path", url: "C:\\example.com\\owner\\repo.git", want: url{ Scheme: "c", User: "", Host: "", Path: "", }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { u, err := ParseURL(tt.url) if tt.wantErr { assert.Error(t, err) return } assert.NoError(t, err) assert.Equal(t, tt.want.Scheme, u.Scheme) assert.Equal(t, tt.want.User, u.User.Username()) assert.Equal(t, tt.want.Host, u.Host) assert.Equal(t, tt.want.Path, u.Path) }) } } func TestRepoInfoFromURL(t *testing.T) { tests := []struct { name string input string wantHost string wantOwner string wantRepo string wantErr bool wantErrMsg string }{ { name: "github.com URL", input: "https://github.com/monalisa/octo-cat.git", wantHost: "github.com", wantOwner: "monalisa", wantRepo: "octo-cat", }, { name: "github.com URL with trailing slash", input: "https://github.com/monalisa/octo-cat/", wantHost: "github.com", wantOwner: "monalisa", wantRepo: "octo-cat", }, { name: "www.github.com URL", input: "http://www.GITHUB.com/monalisa/octo-cat.git", wantHost: "github.com", wantOwner: "monalisa", wantRepo: "octo-cat", }, { name: "too many path components", input: "https://github.com/monalisa/octo-cat/pulls", wantErr: true, wantErrMsg: "invalid path: /monalisa/octo-cat/pulls", }, { name: "non-GitHub hostname", input: "https://example.com/one/two", wantHost: "example.com", wantOwner: "one", wantRepo: "two", }, { name: "filesystem path", input: "/path/to/file", wantErr: true, wantErrMsg: "no hostname detected", }, { name: "filesystem path with scheme", input: "file:///path/to/file", wantErr: true, wantErrMsg: "no hostname detected", }, { name: "github.com SSH URL", input: "ssh://github.com/monalisa/octo-cat.git", wantHost: "github.com", wantOwner: "monalisa", wantRepo: "octo-cat", }, { name: "github.com HTTPS+SSH URL", input: "https+ssh://github.com/monalisa/octo-cat.git", wantHost: "github.com", wantOwner: "monalisa", wantRepo: "octo-cat", }, { name: "github.com git URL", input: "git://github.com/monalisa/octo-cat.git", wantHost: "github.com", wantOwner: "monalisa", wantRepo: "octo-cat", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { u, err := url.Parse(tt.input) assert.NoError(t, err) host, owner, repo, err := RepoInfoFromURL(u) if tt.wantErr { assert.EqualError(t, err, tt.wantErrMsg) return } assert.NoError(t, err) assert.Equal(t, tt.wantHost, host) assert.Equal(t, tt.wantOwner, owner) assert.Equal(t, tt.wantRepo, repo) }) } } go-gh-1.2.1/internal/repository/000077500000000000000000000000001437723465500165675ustar00rootroot00000000000000go-gh-1.2.1/internal/repository/repository.go000066400000000000000000000005641437723465500213420ustar00rootroot00000000000000package 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 } go-gh-1.2.1/internal/set/000077500000000000000000000000001437723465500151435ustar00rootroot00000000000000go-gh-1.2.1/internal/set/string_set.go000066400000000000000000000020461437723465500176550ustar00rootroot00000000000000package set var exists = struct{}{} type stringSet struct { v []string m map[string]struct{} } func NewStringSet() *stringSet { s := &stringSet{} s.m = make(map[string]struct{}) s.v = []string{} return s } func (s *stringSet) Add(value string) { if s.Contains(value) { return } s.m[value] = exists s.v = append(s.v, value) } func (s *stringSet) AddValues(values []string) { for _, v := range values { s.Add(v) } } func (s *stringSet) Remove(value string) { if !s.Contains(value) { return } delete(s.m, value) s.v = sliceWithout(s.v, value) } func sliceWithout(s []string, v string) []string { idx := -1 for i, item := range s { if item == v { idx = i break } } if idx < 0 { return s } return append(s[:idx], s[idx+1:]...) } func (s *stringSet) RemoveValues(values []string) { for _, v := range values { s.Remove(v) } } func (s *stringSet) Contains(value string) bool { _, c := s.m[value] return c } func (s *stringSet) Len() int { return len(s.m) } func (s *stringSet) ToSlice() []string { return s.v } go-gh-1.2.1/internal/set/string_set_test.go000066400000000000000000000010041437723465500207050ustar00rootroot00000000000000package set import ( "testing" "github.com/stretchr/testify/assert" ) func Test_StringSlice_ToSlice(t *testing.T) { s := NewStringSet() s.Add("one") s.Add("two") s.Add("three") s.Add("two") assert.Equal(t, []string{"one", "two", "three"}, s.ToSlice()) } func Test_StringSlice_Remove(t *testing.T) { s := NewStringSet() s.Add("one") s.Add("two") s.Add("three") s.Remove("two") assert.Equal(t, []string{"one", "three"}, s.ToSlice()) assert.False(t, s.Contains("two")) assert.Equal(t, 2, s.Len()) } go-gh-1.2.1/internal/yamlmap/000077500000000000000000000000001437723465500160105ustar00rootroot00000000000000go-gh-1.2.1/internal/yamlmap/yaml_map.go000066400000000000000000000112101437723465500201310ustar00rootroot00000000000000// Package yamlmap is a wrapper of gopkg.in/yaml.v3 for interacting // with yaml data as if it were a map. package yamlmap import ( "errors" "gopkg.in/yaml.v3" ) const ( modified = "modifed" ) type Map struct { *yaml.Node } var ErrNotFound = errors.New("not found") var ErrInvalidYaml = errors.New("invalid yaml") var ErrInvalidFormat = errors.New("invalid format") func StringValue(value string) *Map { return &Map{&yaml.Node{ Kind: yaml.ScalarNode, Tag: "!!str", Value: value, }} } func MapValue() *Map { return &Map{&yaml.Node{ Kind: yaml.MappingNode, Tag: "!!map", }} } func Unmarshal(data []byte) (*Map, error) { var root yaml.Node err := yaml.Unmarshal(data, &root) if err != nil { return nil, ErrInvalidYaml } if len(root.Content) == 0 { return MapValue(), nil } if root.Content[0].Kind != yaml.MappingNode { return nil, ErrInvalidFormat } return &Map{root.Content[0]}, nil } func Marshal(m *Map) ([]byte, error) { return yaml.Marshal(m.Node) } func (m *Map) AddEntry(key string, value *Map) { keyNode := &yaml.Node{ Kind: yaml.ScalarNode, Tag: "!!str", Value: key, } m.Content = append(m.Content, keyNode, value.Node) m.SetModified() } func (m *Map) Empty() bool { return m.Content == nil || len(m.Content) == 0 } func (m *Map) FindEntry(key string) (*Map, error) { // Note: The content slice of a yamlMap looks like [key1, value1, key2, value2, ...]. // When iterating over the content slice we only want to compare the keys of the yamlMap. for i, v := range m.Content { if i%2 != 0 { continue } if v.Value == key { if i+1 < len(m.Content) { return &Map{m.Content[i+1]}, nil } } } return nil, ErrNotFound } func (m *Map) Keys() []string { // Note: The content slice of a yamlMap looks like [key1, value1, key2, value2, ...]. // When iterating over the content slice we only want to select the keys of the yamlMap. keys := []string{} for i, v := range m.Content { if i%2 != 0 { continue } keys = append(keys, v.Value) } return keys } func (m *Map) RemoveEntry(key string) error { // Note: The content slice of a yamlMap looks like [key1, value1, key2, value2, ...]. // When iterating over the content slice we only want to compare the keys of the yamlMap. // If we find they key to remove, remove the key and its value from the content slice. found, skipNext := false, false newContent := []*yaml.Node{} for i, v := range m.Content { if skipNext { skipNext = false continue } if i%2 != 0 || v.Value != key { newContent = append(newContent, v) } else { found = true skipNext = true m.SetModified() } } if !found { return ErrNotFound } m.Content = newContent return nil } func (m *Map) SetEntry(key string, value *Map) { // Note: The content slice of a yamlMap looks like [key1, value1, key2, value2, ...]. // When iterating over the content slice we only want to compare the keys of the yamlMap. // If we find they key to set, set the next item in the content slice to the new value. m.SetModified() for i, v := range m.Content { if i%2 != 0 || v.Value != key { continue } if v.Value == key { if i+1 < len(m.Content) { m.Content[i+1] = value.Node return } } } m.AddEntry(key, value) } // Note: This is a hack to introduce the concept of modified/unmodified // on top of gopkg.in/yaml.v3. This works by setting the Value property // of a MappingNode to a specific value and then later checking if the // node's Value property is that specific value. When a MappingNode gets // output as a string the Value property is not used, thus changing it // has no impact for our purposes. func (m *Map) SetModified() { // Can not mark a non-mapping node as modified if m.Node.Kind != yaml.MappingNode && m.Node.Tag == "!!null" { m.Node.Kind = yaml.MappingNode m.Node.Tag = "!!map" } if m.Node.Kind == yaml.MappingNode { m.Node.Value = modified } } // Traverse map using BFS to set all nodes as unmodified. func (m *Map) SetUnmodified() { i := 0 queue := []*yaml.Node{m.Node} for { if i > (len(queue) - 1) { break } q := queue[i] i = i + 1 if q.Kind != yaml.MappingNode { continue } q.Value = "" queue = append(queue, q.Content...) } } // Traverse map using BFS to searach for any nodes that have been modified. func (m *Map) IsModified() bool { i := 0 queue := []*yaml.Node{m.Node} for { if i > (len(queue) - 1) { break } q := queue[i] i = i + 1 if q.Kind != yaml.MappingNode { continue } if q.Value == modified { return true } queue = append(queue, q.Content...) } return false } func (m *Map) String() string { data, err := Marshal(m) if err != nil { return "" } return string(data) } go-gh-1.2.1/internal/yamlmap/yaml_map_test.go000066400000000000000000000122641437723465500212020ustar00rootroot00000000000000package yamlmap import ( "testing" "github.com/stretchr/testify/assert" ) func TestMapAddEntry(t *testing.T) { tests := []struct { name string key string value string wantValue string wantLength int }{ { name: "add entry with key that is not present", key: "notPresent", value: "test1", wantValue: "test1", wantLength: 10, }, { name: "add entry with key that is already present", key: "erroneous", value: "test2", wantValue: "same", wantLength: 10, }, } for _, tt := range tests { m := testMap() t.Run(tt.name, func(t *testing.T) { m.AddEntry(tt.key, StringValue(tt.value)) entry, err := m.FindEntry(tt.key) assert.NoError(t, err) assert.Equal(t, tt.wantValue, entry.Value) assert.Equal(t, tt.wantLength, len(m.Content)) assert.True(t, m.IsModified()) }) } } func TestMapEmpty(t *testing.T) { m := blankMap() assert.Equal(t, true, m.Empty()) m.AddEntry("test", StringValue("test")) assert.Equal(t, false, m.Empty()) } func TestMapFindEntry(t *testing.T) { tests := []struct { name string key string output string wantErr bool }{ { name: "find key", key: "valid", output: "present", }, { name: "find key that is not present", key: "invalid", wantErr: true, }, { name: "find key with blank value", key: "blank", output: "", }, { name: "find key that has same content as a value", key: "same", output: "logical", }, } for _, tt := range tests { m := testMap() t.Run(tt.name, func(t *testing.T) { out, err := m.FindEntry(tt.key) if tt.wantErr { assert.EqualError(t, err, "not found") assert.False(t, m.IsModified()) return } assert.NoError(t, err) assert.Equal(t, tt.output, out.Value) assert.False(t, m.IsModified()) }) } } func TestMapFindEntryModified(t *testing.T) { m := testMap() entry, err := m.FindEntry("valid") assert.NoError(t, err) assert.Equal(t, "present", entry.Value) entry.Value = "test" assert.Equal(t, "test", entry.Value) entry2, err := m.FindEntry("valid") assert.NoError(t, err) assert.Equal(t, "test", entry2.Value) } func TestMapKeys(t *testing.T) { tests := []struct { name string m *Map wantKeys []string }{ { name: "keys for full map", m: testMap(), wantKeys: []string{"valid", "erroneous", "blank", "same"}, }, { name: "keys for empty map", m: blankMap(), wantKeys: []string{}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { keys := tt.m.Keys() assert.Equal(t, tt.wantKeys, keys) assert.False(t, tt.m.IsModified()) }) } } func TestMapRemoveEntry(t *testing.T) { tests := []struct { name string key string wantLength int wantErr bool }{ { name: "remove key", key: "erroneous", wantLength: 6, }, { name: "remove key that is not present", key: "invalid", wantLength: 8, wantErr: true, }, { name: "remove key that has same content as a value", key: "same", wantLength: 6, }, } for _, tt := range tests { m := testMap() t.Run(tt.name, func(t *testing.T) { err := m.RemoveEntry(tt.key) if tt.wantErr { assert.EqualError(t, err, "not found") assert.False(t, m.IsModified()) } else { assert.NoError(t, err) assert.True(t, m.IsModified()) } assert.Equal(t, tt.wantLength, len(m.Content)) _, err = m.FindEntry(tt.key) assert.EqualError(t, err, "not found") }) } } func TestMapSetEntry(t *testing.T) { tests := []struct { name string key string value *Map wantLength int }{ { name: "sets key that is not present", key: "not", value: StringValue("present"), wantLength: 10, }, { name: "sets key that is present", key: "erroneous", value: StringValue("not same"), wantLength: 8, }, } for _, tt := range tests { m := testMap() t.Run(tt.name, func(t *testing.T) { m.SetEntry(tt.key, tt.value) assert.True(t, m.IsModified()) assert.Equal(t, tt.wantLength, len(m.Content)) e, err := m.FindEntry(tt.key) assert.NoError(t, err) assert.Equal(t, tt.value.Value, e.Value) }) } } func TestUnmarshal(t *testing.T) { tests := []struct { name string data []byte wantErr string wantEmpty bool }{ { name: "valid yaml", data: []byte(`{test: "data"}`), }, { name: "empty yaml", data: []byte(``), wantEmpty: true, }, { name: "invalid yaml", data: []byte(`{test: `), wantErr: "invalid yaml", }, { name: "invalid format", data: []byte(`data`), wantErr: "invalid format", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { m, err := Unmarshal(tt.data) if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) assert.Nil(t, m) return } assert.NoError(t, err) assert.Equal(t, tt.wantEmpty, m.Empty()) }) } } func testMap() *Map { var data = ` valid: present erroneous: same blank: same: logical ` m, _ := Unmarshal([]byte(data)) return m } func blankMap() *Map { return MapValue() } go-gh-1.2.1/pkg/000077500000000000000000000000001437723465500133155ustar00rootroot00000000000000go-gh-1.2.1/pkg/api/000077500000000000000000000000001437723465500140665ustar00rootroot00000000000000go-gh-1.2.1/pkg/api/client.go000066400000000000000000000137621437723465500157040ustar00rootroot00000000000000// 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 } go-gh-1.2.1/pkg/api/errors.go000066400000000000000000000103501437723465500157300ustar00rootroot00000000000000package api import ( "encoding/json" "fmt" "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 Headers http.Header Message string RequestURL *url.URL StatusCode int } // HTTPErrorItem stores additional information about an error response // returned from the GitHub API. type HTTPErrorItem struct { Message string Resource string Field string Code string } // Allow HTTPError to satisfy error interface. 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) } // GQLError represents an error response from GitHub GraphQL API. type GQLError struct { Errors []GQLErrorItem } // GQLErrorItem stores additional information about an error response // returned from the GitHub GraphQL API. type GQLErrorItem struct { Message string Path []interface{} Type string } // Allow GQLError to satisfy error interface. func (gr GQLError) 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 determines if the GQLError 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 { for _, e := range gr.Errors { if e.Type != expectType || !matchPath(e.pathString(), expectPath) { return false } } return true } func (ge GQLErrorItem) 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() } func matchPath(p, expect string) bool { if strings.HasSuffix(expect, ".") { return strings.HasPrefix(p, expect) || p == strings.TrimSuffix(expect, ".") } return p == expect } // HandleHTTPError parses a http.Response into a HTTPError. func HandleHTTPError(resp *http.Response) error { httpError := HTTPError{ Headers: resp.Header, RequestURL: resp.Request.URL, StatusCode: resp.StatusCode, } if !jsonTypeRE.MatchString(resp.Header.Get(contentType)) { 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 } // Convert common error codes to human readable messages // See https://docs.github.com/en/rest/overview/resources-in-the-rest-api#client-errors for more details. func errorCodeToMessage(code string) string { switch code { case "missing", "missing_field": return "is missing" case "invalid", "unprocessable": return "is invalid" case "already_exists": return "already exists" default: return code } } go-gh-1.2.1/pkg/api/errors_test.go000066400000000000000000000025271437723465500167760ustar00rootroot00000000000000package api import ( "testing" "github.com/stretchr/testify/assert" ) func TestGQLErrorMatch(t *testing.T) { tests := []struct { name string error GQLError kind string path string wantMatch bool }{ { name: "matches path and type", error: GQLError{Errors: []GQLErrorItem{ {Path: []interface{}{"repository", "issue"}, Type: "NOT_FOUND"}, }}, kind: "NOT_FOUND", path: "repository.issue", wantMatch: true, }, { name: "matches base path and type", error: GQLError{Errors: []GQLErrorItem{ {Path: []interface{}{"repository", "issue"}, Type: "NOT_FOUND"}, }}, kind: "NOT_FOUND", path: "repository.", wantMatch: true, }, { name: "does not match path but matches type", error: GQLError{Errors: []GQLErrorItem{ {Path: []interface{}{"repository", "issue"}, Type: "NOT_FOUND"}, }}, kind: "NOT_FOUND", path: "label.title", wantMatch: false, }, { name: "matches path but not type", error: GQLError{Errors: []GQLErrorItem{ {Path: []interface{}{"repository", "issue"}, Type: "NOT_FOUND"}, }}, kind: "UNKNOWN", path: "repository.issue", wantMatch: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.wantMatch, tt.error.Match(tt.kind, tt.path)) }) } } go-gh-1.2.1/pkg/auth/000077500000000000000000000000001437723465500142565ustar00rootroot00000000000000go-gh-1.2.1/pkg/auth/auth.go000066400000000000000000000105571437723465500155560ustar00rootroot00000000000000// Package auth is a set of functions for retrieving authentication tokens // and authenticated hosts. package auth import ( "os" "os/exec" "strconv" "strings" "github.com/cli/go-gh/internal/set" "github.com/cli/go-gh/pkg/config" "github.com/cli/safeexec" ) const ( codespaces = "CODESPACES" defaultSource = "default" ghEnterpriseToken = "GH_ENTERPRISE_TOKEN" ghHost = "GH_HOST" ghToken = "GH_TOKEN" github = "github.com" githubEnterpriseToken = "GITHUB_ENTERPRISE_TOKEN" githubToken = "GITHUB_TOKEN" hostsKey = "hosts" localhost = "github.localhost" oauthToken = "oauth_token" ) // TokenForHost retrieves an authentication token and the source of that token for the specified // host. The source can be either an environment variable, configuration file, or the system // keyring. In the latter case, this shells out to "gh auth token" to obtain the token. // // Returns "", "default" if no applicable token is found. func TokenForHost(host string) (string, string) { if token, source := TokenFromEnvOrConfig(host); token != "" { return token, source } if ghExe, err := safeexec.LookPath("gh"); err == nil { if token, source := tokenFromGh(ghExe, host); token != "" { return token, source } } return "", defaultSource } // TokenFromEnvOrConfig retrieves an authentication token from environment variables or the config // file as fallback, but does not support reading the token from system keyring. Most consumers // should use TokenForHost. func TokenFromEnvOrConfig(host string) (string, string) { cfg, _ := config.Read() return tokenForHost(cfg, host) } func tokenForHost(cfg *config.Config, host string) (string, string) { host = normalizeHostname(host) if isEnterprise(host) { if token := os.Getenv(ghEnterpriseToken); token != "" { return token, ghEnterpriseToken } if token := os.Getenv(githubEnterpriseToken); token != "" { return token, githubEnterpriseToken } if isCodespaces, _ := strconv.ParseBool(os.Getenv(codespaces)); isCodespaces { if token := os.Getenv(githubToken); token != "" { return token, githubToken } } if cfg != nil { token, _ := cfg.Get([]string{hostsKey, host, oauthToken}) return token, oauthToken } } if token := os.Getenv(ghToken); token != "" { return token, ghToken } if token := os.Getenv(githubToken); token != "" { return token, githubToken } if cfg != nil { token, _ := cfg.Get([]string{hostsKey, host, oauthToken}) return token, oauthToken } return "", defaultSource } func tokenFromGh(path string, host string) (string, string) { cmd := exec.Command(path, "auth", "token", "--secure-storage", "--hostname", host) result, err := cmd.Output() if err != nil { return "", "gh" } return strings.TrimSpace(string(result)), "gh" } // KnownHosts retrieves a list of hosts that have corresponding // authentication tokens, either from environment variables // or from the configuration file. // Returns an empty string slice if no hosts are found. func KnownHosts() []string { cfg, _ := config.Read() return knownHosts(cfg) } func knownHosts(cfg *config.Config) []string { hosts := set.NewStringSet() if host := os.Getenv(ghHost); host != "" { hosts.Add(host) } if token, _ := tokenForHost(cfg, github); token != "" { hosts.Add(github) } if cfg != nil { keys, err := cfg.Keys([]string{hostsKey}) if err == nil { hosts.AddValues(keys) } } return hosts.ToSlice() } // DefaultHost retrieves an authenticated host and the source of host. // The source can be either an environment variable or from the // configuration file. // Returns "github.com", "default" if no viable host is found. func DefaultHost() (string, string) { cfg, _ := config.Read() return defaultHost(cfg) } func defaultHost(cfg *config.Config) (string, string) { if host := os.Getenv(ghHost); host != "" { return host, ghHost } if cfg != nil { keys, err := cfg.Keys([]string{hostsKey}) if err == nil && len(keys) == 1 { return keys[0], hostsKey } } return github, defaultSource } func isEnterprise(host string) bool { return host != github && host != localhost } func normalizeHostname(host string) string { hostname := strings.ToLower(host) if strings.HasSuffix(hostname, "."+github) { return github } if strings.HasSuffix(hostname, "."+localhost) { return localhost } return hostname } go-gh-1.2.1/pkg/auth/auth_test.go000066400000000000000000000161021437723465500166050ustar00rootroot00000000000000package auth import ( "testing" "github.com/cli/go-gh/pkg/config" "github.com/stretchr/testify/assert" ) func TestTokenForHost(t *testing.T) { tests := []struct { name string host string githubToken string githubEnterpriseToken string ghToken string ghEnterpriseToken string config *config.Config wantToken string wantSource string wantNotFound bool }{ { name: "token for github.com with no env tokens and no config token", host: "github.com", config: testNoHostsConfig(), wantToken: "", wantSource: "oauth_token", wantNotFound: true, }, { name: "token for enterprise.com with no env tokens and no config token", host: "enterprise.com", config: testNoHostsConfig(), wantToken: "", wantSource: "oauth_token", wantNotFound: true, }, { name: "token for github.com with GH_TOKEN, GITHUB_TOKEN, and config token", host: "github.com", ghToken: "GH_TOKEN", githubToken: "GITHUB_TOKEN", config: testHostsConfig(), wantToken: "GH_TOKEN", wantSource: "GH_TOKEN", }, { name: "token for github.com with GITHUB_TOKEN, and config token", host: "github.com", githubToken: "GITHUB_TOKEN", config: testHostsConfig(), wantToken: "GITHUB_TOKEN", wantSource: "GITHUB_TOKEN", }, { name: "token for github.com with config token", host: "github.com", config: testHostsConfig(), wantToken: "xxxxxxxxxxxxxxxxxxxx", wantSource: "oauth_token", }, { name: "token for enterprise.com with GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN, and config token", host: "enterprise.com", ghEnterpriseToken: "GH_ENTERPRISE_TOKEN", githubEnterpriseToken: "GITHUB_ENTERPRISE_TOKEN", config: testHostsConfig(), wantToken: "GH_ENTERPRISE_TOKEN", wantSource: "GH_ENTERPRISE_TOKEN", }, { name: "token for enterprise.com with GITHUB_ENTERPRISE_TOKEN, and config token", host: "enterprise.com", githubEnterpriseToken: "GITHUB_ENTERPRISE_TOKEN", config: testHostsConfig(), wantToken: "GITHUB_ENTERPRISE_TOKEN", wantSource: "GITHUB_ENTERPRISE_TOKEN", }, { name: "token for enterprise.com with config token", host: "enterprise.com", config: testHostsConfig(), wantToken: "yyyyyyyyyyyyyyyyyyyy", wantSource: "oauth_token", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv("GITHUB_TOKEN", tt.githubToken) t.Setenv("GITHUB_ENTERPRISE_TOKEN", tt.githubEnterpriseToken) t.Setenv("GH_TOKEN", tt.ghToken) t.Setenv("GH_ENTERPRISE_TOKEN", tt.ghEnterpriseToken) token, source := tokenForHost(tt.config, tt.host) assert.Equal(t, tt.wantToken, token) assert.Equal(t, tt.wantSource, source) }) } } func TestDefaultHost(t *testing.T) { tests := []struct { name string config *config.Config ghHost string wantHost string wantSource string wantNotFound bool }{ { name: "GH_HOST if set", config: testHostsConfig(), ghHost: "test.com", wantHost: "test.com", wantSource: "GH_HOST", }, { name: "authenticated host if only one", config: testSingleHostConfig(), wantHost: "enterprise.com", wantSource: "hosts", }, { name: "default host if more than one authenticated host", config: testHostsConfig(), wantHost: "github.com", wantSource: "default", wantNotFound: true, }, { name: "default host if no authenticated host", config: testNoHostsConfig(), wantHost: "github.com", wantSource: "default", wantNotFound: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.ghHost != "" { t.Setenv("GH_HOST", tt.ghHost) } host, source := defaultHost(tt.config) assert.Equal(t, tt.wantHost, host) assert.Equal(t, tt.wantSource, source) }) } } func TestKnownHosts(t *testing.T) { tests := []struct { name string config *config.Config ghHost string ghToken string wantHosts []string }{ { name: "no known hosts", config: testNoHostsConfig(), wantHosts: []string{}, }, { name: "includes GH_HOST", config: testNoHostsConfig(), ghHost: "test.com", wantHosts: []string{"test.com"}, }, { name: "includes authenticated hosts", config: testHostsConfig(), wantHosts: []string{"github.com", "enterprise.com"}, }, { name: "includes default host if environment auth token", config: testNoHostsConfig(), ghToken: "TOKEN", wantHosts: []string{"github.com"}, }, { name: "deduplicates hosts", config: testHostsConfig(), ghHost: "test.com", ghToken: "TOKEN", wantHosts: []string{"test.com", "github.com", "enterprise.com"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.ghHost != "" { t.Setenv("GH_HOST", tt.ghHost) } if tt.ghToken != "" { t.Setenv("GH_TOKEN", tt.ghToken) } hosts := knownHosts(tt.config) assert.Equal(t, tt.wantHosts, hosts) }) } } func TestIsEnterprise(t *testing.T) { tests := []struct { name string host string wantOut bool }{ { name: "github", host: "github.com", wantOut: false, }, { name: "localhost", host: "github.localhost", wantOut: false, }, { name: "enterprise", host: "mygithub.com", wantOut: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { out := isEnterprise(tt.host) assert.Equal(t, tt.wantOut, out) }) } } func TestNormalizeHostname(t *testing.T) { tests := []struct { name string host string wantHost string }{ { name: "github domain", host: "test.github.com", wantHost: "github.com", }, { name: "capitalized", host: "GitHub.com", wantHost: "github.com", }, { name: "localhost domain", host: "test.github.localhost", wantHost: "github.localhost", }, { name: "enterprise domain", host: "mygithub.com", wantHost: "mygithub.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { normalized := normalizeHostname(tt.host) assert.Equal(t, tt.wantHost, normalized) }) } } func testNoHostsConfig() *config.Config { var data = `` return config.ReadFromString(data) } func testSingleHostConfig() *config.Config { var data = ` hosts: enterprise.com: user: user2 oauth_token: yyyyyyyyyyyyyyyyyyyy git_protocol: https ` return config.ReadFromString(data) } func testHostsConfig() *config.Config { var data = ` hosts: github.com: user: user1 oauth_token: xxxxxxxxxxxxxxxxxxxx git_protocol: ssh enterprise.com: user: user2 oauth_token: yyyyyyyyyyyyyyyyyyyy git_protocol: https ` return config.ReadFromString(data) } go-gh-1.2.1/pkg/browser/000077500000000000000000000000001437723465500150005ustar00rootroot00000000000000go-gh-1.2.1/pkg/browser/browser.go000066400000000000000000000034761437723465500170240ustar00rootroot00000000000000// Package browser facilitates opening of URLs in a web browser. package browser import ( "io" "os" "os/exec" cliBrowser "github.com/cli/browser" "github.com/cli/go-gh/pkg/config" "github.com/cli/safeexec" "github.com/google/shlex" ) // Browser represents a web browser that can be used to open up URLs. type Browser struct { launcher string stderr io.Writer stdout io.Writer } // New initializes a Browser. If a launcher is not specified // one is determined based on environment variables or from the // configuration file. // The order of precedence for determining a launcher is: // - Specified launcher; // - GH_BROWSER environment variable; // - browser option from configuration file; // - BROWSER environment variable. func New(launcher string, stdout, stderr io.Writer) Browser { if launcher == "" { launcher = resolveLauncher() } b := Browser{ launcher: launcher, stderr: stderr, stdout: stdout, } return b } // Browse opens the launcher and navigates to the specified URL. func (b *Browser) Browse(url string) error { return b.browse(url, nil) } func (b *Browser) browse(url string, env []string) error { if b.launcher == "" { return cliBrowser.OpenURL(url) } launcherArgs, err := shlex.Split(b.launcher) if err != nil { return err } launcherExe, err := safeexec.LookPath(launcherArgs[0]) if err != nil { return err } args := append(launcherArgs[1:], url) cmd := exec.Command(launcherExe, args...) cmd.Stdout = b.stdout cmd.Stderr = b.stderr if env != nil { cmd.Env = env } return cmd.Run() } func resolveLauncher() string { if ghBrowser := os.Getenv("GH_BROWSER"); ghBrowser != "" { return ghBrowser } cfg, err := config.Read() if err == nil { if cfgBrowser, _ := cfg.Get([]string{"browser"}); cfgBrowser != "" { return cfgBrowser } } return os.Getenv("BROWSER") } go-gh-1.2.1/pkg/browser/browser_test.go000066400000000000000000000044151437723465500200550ustar00rootroot00000000000000package browser import ( "bytes" "fmt" "os" "testing" "github.com/cli/go-gh/pkg/config" "github.com/stretchr/testify/assert" ) func TestHelperProcess(t *testing.T) { if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" { return } fmt.Fprintf(os.Stdout, "%v", os.Args[3:]) os.Exit(0) } func TestBrowse(t *testing.T) { launcher := fmt.Sprintf("%q -test.run=TestHelperProcess -- chrome", os.Args[0]) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} b := Browser{launcher: launcher, stdout: stdout, stderr: stderr} err := b.browse("github.com", []string{"GH_WANT_HELPER_PROCESS=1"}) assert.NoError(t, err) assert.Equal(t, "[chrome github.com]", stdout.String()) assert.Equal(t, "", stderr.String()) } func TestResolveLauncher(t *testing.T) { tests := []struct { name string env map[string]string config *config.Config wantLauncher string }{ { name: "GH_BROWSER set", env: map[string]string{ "GH_BROWSER": "GH_BROWSER", }, wantLauncher: "GH_BROWSER", }, { name: "config browser set", config: config.ReadFromString("browser: CONFIG_BROWSER"), wantLauncher: "CONFIG_BROWSER", }, { name: "BROWSER set", env: map[string]string{ "BROWSER": "BROWSER", }, wantLauncher: "BROWSER", }, { name: "GH_BROWSER and config browser set", env: map[string]string{ "GH_BROWSER": "GH_BROWSER", }, config: config.ReadFromString("browser: CONFIG_BROWSER"), wantLauncher: "GH_BROWSER", }, { name: "config browser and BROWSER set", env: map[string]string{ "BROWSER": "BROWSER", }, config: config.ReadFromString("browser: CONFIG_BROWSER"), wantLauncher: "CONFIG_BROWSER", }, { name: "GH_BROWSER and BROWSER set", env: map[string]string{ "BROWSER": "BROWSER", "GH_BROWSER": "GH_BROWSER", }, wantLauncher: "GH_BROWSER", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if tt.env != nil { for k, v := range tt.env { t.Setenv(k, v) } } if tt.config != nil { old := config.Read config.Read = func() (*config.Config, error) { return tt.config, nil } defer func() { config.Read = old }() } launcher := resolveLauncher() assert.Equal(t, tt.wantLauncher, launcher) }) } } go-gh-1.2.1/pkg/config/000077500000000000000000000000001437723465500145625ustar00rootroot00000000000000go-gh-1.2.1/pkg/config/config.go000066400000000000000000000205061437723465500163610ustar00rootroot00000000000000// Package config is a set of types for interacting with the gh configuration files. // Note: This package is intended for use only in gh, any other use cases are subject // to breakage and non-backwards compatible updates. package config import ( "errors" "io" "os" "path/filepath" "runtime" "sync" "github.com/cli/go-gh/internal/yamlmap" ) const ( appData = "AppData" ghConfigDir = "GH_CONFIG_DIR" localAppData = "LocalAppData" xdgConfigHome = "XDG_CONFIG_HOME" xdgDataHome = "XDG_DATA_HOME" xdgStateHome = "XDG_STATE_HOME" ) var ( cfg *Config once sync.Once loadErr error ) // Config is a in memory representation of the gh configuration files. // It can be thought of as map where entries consist of a key that // correspond to either a string value or a map value, allowing for // multi-level maps. type Config struct { entries *yamlmap.Map mu sync.RWMutex } // Get a string value from a Config. // The keys argument is a sequence of key values so that nested // entries can be retrieved. A undefined string will be returned // if trying to retrieve a key that corresponds to a map value. // Returns "", KeyNotFoundError if any of the keys can not be found. func (c *Config) Get(keys []string) (string, error) { c.mu.RLock() defer c.mu.RUnlock() m := c.entries for _, key := range keys { var err error m, err = m.FindEntry(key) if err != nil { return "", KeyNotFoundError{key} } } return m.Value, nil } // Keys enumerates a Config's keys. // The keys argument is a sequence of key values so that nested // map values can be have their keys enumerated. // Returns nil, KeyNotFoundError if any of the keys can not be found. func (c *Config) Keys(keys []string) ([]string, error) { c.mu.RLock() defer c.mu.RUnlock() m := c.entries for _, key := range keys { var err error m, err = m.FindEntry(key) if err != nil { return nil, KeyNotFoundError{key} } } return m.Keys(), nil } // Remove an entry from a Config. // The keys argument is a sequence of key values so that nested // entries can be removed. Removing an entry that has nested // entries removes those also. // Returns KeyNotFoundError if any of the keys can not be found. func (c *Config) Remove(keys []string) error { c.mu.Lock() defer c.mu.Unlock() m := c.entries for i := 0; i < len(keys)-1; i++ { var err error key := keys[i] m, err = m.FindEntry(key) if err != nil { return KeyNotFoundError{key} } } err := m.RemoveEntry(keys[len(keys)-1]) if err != nil { return KeyNotFoundError{keys[len(keys)-1]} } return nil } // Set a string value in a Config. // The keys argument is a sequence of key values so that nested // entries can be set. If any of the keys do not exist they will // be created. func (c *Config) Set(keys []string, value string) { c.mu.Lock() defer c.mu.Unlock() m := c.entries for i := 0; i < len(keys)-1; i++ { key := keys[i] entry, err := m.FindEntry(key) if err != nil { entry = yamlmap.MapValue() m.AddEntry(key, entry) } m = entry } m.SetEntry(keys[len(keys)-1], yamlmap.StringValue(value)) } // Read gh configuration files from the local file system and // return a Config. var Read = func() (*Config, error) { once.Do(func() { cfg, loadErr = load(generalConfigFile(), hostsConfigFile()) }) return cfg, loadErr } // ReadFromString takes a yaml string and returns a Config. // Note: This is only used for testing, and should not be // relied upon in production. func ReadFromString(str string) *Config { m, _ := mapFromString(str) if m == nil { m = yamlmap.MapValue() } return &Config{entries: m} } // Write gh configuration files to the local file system. // It will only write gh configuration files that have been modified // since last being read. func Write(c *Config) error { c.mu.Lock() defer c.mu.Unlock() hosts, err := c.entries.FindEntry("hosts") if err == nil && hosts.IsModified() { err := writeFile(hostsConfigFile(), []byte(hosts.String())) if err != nil { return err } hosts.SetUnmodified() } if c.entries.IsModified() { // Hosts gets written to a different file above so remove it // before writing and add it back in after writing. hostsMap, hostsErr := c.entries.FindEntry("hosts") if hostsErr == nil { _ = c.entries.RemoveEntry("hosts") } err := writeFile(generalConfigFile(), []byte(c.entries.String())) if err != nil { return err } c.entries.SetUnmodified() if hostsErr == nil { c.entries.AddEntry("hosts", hostsMap) } } return nil } func load(generalFilePath, hostsFilePath string) (*Config, error) { generalMap, err := mapFromFile(generalFilePath) 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, err } if generalMap == nil || generalMap.Empty() { generalMap, _ = mapFromString(defaultGeneralEntries) } hostsMap, err := mapFromFile(hostsFilePath) 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, err } if hostsMap != nil && !hostsMap.Empty() { generalMap.AddEntry("hosts", hostsMap) } return &Config{entries: generalMap}, nil } func generalConfigFile() string { return filepath.Join(ConfigDir(), "config.yml") } func hostsConfigFile() string { return filepath.Join(ConfigDir(), "hosts.yml") } func mapFromFile(filename string) (*yamlmap.Map, error) { data, err := readFile(filename) if err != nil { return nil, err } return yamlmap.Unmarshal(data) } func mapFromString(str string) (*yamlmap.Map, error) { return yamlmap.Unmarshal([]byte(str)) } // Config path precedence: GH_CONFIG_DIR, XDG_CONFIG_HOME, AppData (windows only), HOME. func ConfigDir() string { var path string if a := os.Getenv(ghConfigDir); a != "" { path = a } else if b := os.Getenv(xdgConfigHome); b != "" { path = filepath.Join(b, "gh") } else if c := os.Getenv(appData); runtime.GOOS == "windows" && c != "" { path = filepath.Join(c, "GitHub CLI") } else { d, _ := os.UserHomeDir() path = filepath.Join(d, ".config", "gh") } return path } // State path precedence: XDG_STATE_HOME, LocalAppData (windows only), HOME. func StateDir() string { var path string if a := os.Getenv(xdgStateHome); a != "" { path = filepath.Join(a, "gh") } else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" { path = filepath.Join(b, "GitHub CLI") } else { c, _ := os.UserHomeDir() path = filepath.Join(c, ".local", "state", "gh") } return path } // Data path precedence: XDG_DATA_HOME, LocalAppData (windows only), HOME. func DataDir() string { var path string if a := os.Getenv(xdgDataHome); a != "" { path = filepath.Join(a, "gh") } else if b := os.Getenv(localAppData); runtime.GOOS == "windows" && b != "" { path = filepath.Join(b, "GitHub CLI") } else { c, _ := os.UserHomeDir() path = filepath.Join(c, ".local", "share", "gh") } return path } func readFile(filename string) ([]byte, error) { f, err := os.Open(filename) if err != nil { return nil, err } defer f.Close() data, err := io.ReadAll(f) if err != nil { return nil, err } return data, nil } func writeFile(filename string, data []byte) error { err := os.MkdirAll(filepath.Dir(filename), 0771) if err != nil { return err } file, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return err } defer file.Close() _, err = file.Write(data) return err } var defaultGeneralEntries = ` # What protocol to use when performing git operations. Supported values: ssh, https git_protocol: https # What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment. editor: # When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled prompt: enabled # A pager program to send command output to, e.g. "less". Set the value to "cat" to disable the pager. pager: # Aliases allow you to create nicknames for gh commands aliases: co: pr checkout # The path to a unix socket through which send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport. http_unix_socket: # What web browser gh should use when opening URLs. If blank, will refer to environment. browser: ` go-gh-1.2.1/pkg/config/config_test.go000066400000000000000000000372541437723465500174300ustar00rootroot00000000000000package config import ( "fmt" "os" "path/filepath" "runtime" "testing" "github.com/stretchr/testify/assert" ) func TestConfigDir(t *testing.T) { tempDir := t.TempDir() tests := []struct { name string onlyWindows bool env map[string]string output string }{ { name: "HOME/USERPROFILE specified", env: map[string]string{ "GH_CONFIG_DIR": "", "XDG_CONFIG_HOME": "", "AppData": "", "USERPROFILE": tempDir, "HOME": tempDir, }, output: filepath.Join(tempDir, ".config", "gh"), }, { name: "GH_CONFIG_DIR specified", env: map[string]string{ "GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"), }, output: filepath.Join(tempDir, "gh_config_dir"), }, { name: "XDG_CONFIG_HOME specified", env: map[string]string{ "XDG_CONFIG_HOME": tempDir, }, output: filepath.Join(tempDir, "gh"), }, { name: "GH_CONFIG_DIR and XDG_CONFIG_HOME specified", env: map[string]string{ "GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"), "XDG_CONFIG_HOME": tempDir, }, output: filepath.Join(tempDir, "gh_config_dir"), }, { name: "AppData specified", onlyWindows: true, env: map[string]string{ "AppData": tempDir, }, output: filepath.Join(tempDir, "GitHub CLI"), }, { name: "GH_CONFIG_DIR and AppData specified", onlyWindows: true, env: map[string]string{ "GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"), "AppData": tempDir, }, output: filepath.Join(tempDir, "gh_config_dir"), }, { name: "XDG_CONFIG_HOME and AppData specified", onlyWindows: true, env: map[string]string{ "XDG_CONFIG_HOME": tempDir, "AppData": tempDir, }, output: filepath.Join(tempDir, "gh"), }, } for _, tt := range tests { if tt.onlyWindows && runtime.GOOS != "windows" { continue } t.Run(tt.name, func(t *testing.T) { if tt.env != nil { for k, v := range tt.env { t.Setenv(k, v) } } assert.Equal(t, tt.output, ConfigDir()) }) } } func TestStateDir(t *testing.T) { tempDir := t.TempDir() tests := []struct { name string onlyWindows bool env map[string]string output string }{ { name: "HOME/USERPROFILE specified", env: map[string]string{ "XDG_STATE_HOME": "", "GH_CONFIG_DIR": "", "XDG_CONFIG_HOME": "", "LocalAppData": "", "USERPROFILE": tempDir, "HOME": tempDir, }, output: filepath.Join(tempDir, ".local", "state", "gh"), }, { name: "XDG_STATE_HOME specified", env: map[string]string{ "XDG_STATE_HOME": tempDir, }, output: filepath.Join(tempDir, "gh"), }, { name: "LocalAppData specified", onlyWindows: true, env: map[string]string{ "LocalAppData": tempDir, }, output: filepath.Join(tempDir, "GitHub CLI"), }, { name: "XDG_STATE_HOME and LocalAppData specified", onlyWindows: true, env: map[string]string{ "XDG_STATE_HOME": tempDir, "LocalAppData": tempDir, }, output: filepath.Join(tempDir, "gh"), }, } for _, tt := range tests { if tt.onlyWindows && runtime.GOOS != "windows" { continue } t.Run(tt.name, func(t *testing.T) { if tt.env != nil { for k, v := range tt.env { t.Setenv(k, v) } } assert.Equal(t, tt.output, StateDir()) }) } } func TestDataDir(t *testing.T) { tempDir := t.TempDir() tests := []struct { name string onlyWindows bool env map[string]string output string }{ { name: "HOME/USERPROFILE specified", env: map[string]string{ "XDG_DATA_HOME": "", "GH_CONFIG_DIR": "", "XDG_CONFIG_HOME": "", "LocalAppData": "", "USERPROFILE": tempDir, "HOME": tempDir, }, output: filepath.Join(tempDir, ".local", "share", "gh"), }, { name: "XDG_DATA_HOME specified", env: map[string]string{ "XDG_DATA_HOME": tempDir, }, output: filepath.Join(tempDir, "gh"), }, { name: "LocalAppData specified", onlyWindows: true, env: map[string]string{ "LocalAppData": tempDir, }, output: filepath.Join(tempDir, "GitHub CLI"), }, { name: "XDG_DATA_HOME and LocalAppData specified", onlyWindows: true, env: map[string]string{ "XDG_DATA_HOME": tempDir, "LocalAppData": tempDir, }, output: filepath.Join(tempDir, "gh"), }, } for _, tt := range tests { if tt.onlyWindows && runtime.GOOS != "windows" { continue } t.Run(tt.name, func(t *testing.T) { if tt.env != nil { for k, v := range tt.env { t.Setenv(k, v) } } assert.Equal(t, tt.output, DataDir()) }) } } func TestLoad(t *testing.T) { tempDir := t.TempDir() globalFilePath := filepath.Join(tempDir, "config.yml") invalidGlobalFilePath := filepath.Join(tempDir, "invalid_config.yml") hostsFilePath := filepath.Join(tempDir, "hosts.yml") invalidHostsFilePath := filepath.Join(tempDir, "invalid_hosts.yml") err := os.WriteFile(globalFilePath, []byte(testGlobalData()), 0755) assert.NoError(t, err) err = os.WriteFile(invalidGlobalFilePath, []byte("invalid"), 0755) assert.NoError(t, err) err = os.WriteFile(hostsFilePath, []byte(testHostsData()), 0755) assert.NoError(t, err) err = os.WriteFile(invalidHostsFilePath, []byte("invalid"), 0755) assert.NoError(t, err) tests := []struct { name string globalConfigPath string hostsConfigPath string wantGitProtocol string wantToken string wantErr bool wantErrMsg string wantGetErr bool }{ { name: "global and hosts files exist", globalConfigPath: globalFilePath, hostsConfigPath: hostsFilePath, wantGitProtocol: "ssh", wantToken: "yyyyyyyyyyyyyyyyyyyy", }, { name: "invalid global file", globalConfigPath: invalidGlobalFilePath, wantErr: true, wantErrMsg: fmt.Sprintf("invalid config file %s: invalid format", filepath.Join(tempDir, "invalid_config.yml")), }, { name: "invalid hosts file", globalConfigPath: globalFilePath, hostsConfigPath: invalidHostsFilePath, wantErr: true, wantErrMsg: fmt.Sprintf("invalid config file %s: invalid format", filepath.Join(tempDir, "invalid_hosts.yml")), }, { name: "global file does not exist and hosts file exist", globalConfigPath: "", hostsConfigPath: hostsFilePath, wantGitProtocol: "https", wantToken: "yyyyyyyyyyyyyyyyyyyy", }, { name: "global file exist and hosts file does not exist", globalConfigPath: globalFilePath, hostsConfigPath: "", wantGitProtocol: "ssh", wantGetErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg, err := load(tt.globalConfigPath, tt.hostsConfigPath) if tt.wantErr { assert.EqualError(t, err, tt.wantErrMsg) return } assert.NoError(t, err) protocol, err := cfg.Get([]string{"git_protocol"}) assert.NoError(t, err) assert.Equal(t, tt.wantGitProtocol, protocol) token, err := cfg.Get([]string{"hosts", "enterprise.com", "oauth_token"}) if tt.wantGetErr { assert.EqualError(t, err, `could not find key "hosts"`) } else { assert.NoError(t, err) } assert.Equal(t, tt.wantToken, token) }) } } func TestWrite(t *testing.T) { tests := []struct { name string createConfig func() *Config wantConfig func() *Config wantErr bool wantErrMsg string }{ { name: "writes config and hosts files", createConfig: func() *Config { cfg := ReadFromString(testFullConfig()) cfg.Set([]string{"editor"}, "vim") cfg.Set([]string{"hosts", "github.com", "git_protocol"}, "https") return cfg }, }, { name: "only writes hosts file", createConfig: func() *Config { cfg := ReadFromString(testFullConfig()) cfg.Set([]string{"hosts", "enterprise.com", "git_protocol"}, "ssh") return cfg }, wantConfig: func() *Config { // The hosts file is writen but not the general config file. // When we use Read in the test the defaultGeneralEntries are used. cfg := ReadFromString(defaultGeneralEntries) cfg.Set([]string{"hosts", "github.com", "user"}, "user1") cfg.Set([]string{"hosts", "github.com", "oauth_token"}, "xxxxxxxxxxxxxxxxxxxx") cfg.Set([]string{"hosts", "github.com", "git_protocol"}, "ssh") cfg.Set([]string{"hosts", "enterprise.com", "user"}, "user2") cfg.Set([]string{"hosts", "enterprise.com", "oauth_token"}, "yyyyyyyyyyyyyyyyyyyy") cfg.Set([]string{"hosts", "enterprise.com", "git_protocol"}, "ssh") return cfg }, }, { name: "only writes config file", createConfig: func() *Config { cfg := ReadFromString(testFullConfig()) cfg.Set([]string{"editor"}, "vim") return cfg }, wantConfig: func() *Config { // The general config file is written but not the hosts config file. // When we use Read in the test there will not be any hosts entries. cfg := ReadFromString(testFullConfig()) cfg.Set([]string{"editor"}, "vim") _ = cfg.Remove([]string{"hosts"}) return cfg }, }, { name: "write default config file keeps comments", createConfig: func() *Config { cfg := ReadFromString(defaultGeneralEntries) cfg.entries.SetModified() return cfg }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tempDir := t.TempDir() t.Setenv("GH_CONFIG_DIR", tempDir) cfg := tt.createConfig() err := Write(cfg) assert.NoError(t, err) loadedCfg, err := load(generalConfigFile(), hostsConfigFile()) assert.NoError(t, err) wantCfg := cfg if tt.wantConfig != nil { wantCfg = tt.wantConfig() } assert.Equal(t, wantCfg.entries.String(), loadedCfg.entries.String()) }) } } func TestGet(t *testing.T) { tests := []struct { name string keys []string wantValue string wantErr bool wantErrMsg string }{ { name: "get git_protocol value", keys: []string{"git_protocol"}, wantValue: "ssh", }, { name: "get editor value", keys: []string{"editor"}, wantValue: "", }, { name: "get prompt value", keys: []string{"prompt"}, wantValue: "enabled", }, { name: "get pager value", keys: []string{"pager"}, wantValue: "less", }, { name: "non-existant key", keys: []string{"unknown"}, wantErr: true, wantErrMsg: `could not find key "unknown"`, wantValue: "", }, { name: "nested key", keys: []string{"nested", "key"}, wantValue: "value", }, { name: "nested key with same name", keys: []string{"nested", "pager"}, wantValue: "more", }, { name: "nested non-existant key", keys: []string{"nested", "invalid"}, wantErr: true, wantErrMsg: `could not find key "invalid"`, wantValue: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := testConfig() value, err := cfg.Get(tt.keys) if tt.wantErr { assert.EqualError(t, err, tt.wantErrMsg) } else { assert.NoError(t, err) } assert.Equal(t, tt.wantValue, value) assert.False(t, cfg.entries.IsModified()) }) } } func TestKeys(t *testing.T) { tests := []struct { name string findKeys []string wantKeys []string wantErr bool wantErrMsg string }{ { name: "top level keys", findKeys: nil, wantKeys: []string{"git_protocol", "editor", "prompt", "pager", "nested"}, }, { name: "nested keys", findKeys: []string{"nested"}, wantKeys: []string{"key", "pager"}, }, { name: "keys for non-existant nested key", findKeys: []string{"unknown"}, wantKeys: nil, wantErr: true, wantErrMsg: `could not find key "unknown"`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := testConfig() ks, err := cfg.Keys(tt.findKeys) if tt.wantErr { assert.EqualError(t, err, tt.wantErrMsg) } else { assert.NoError(t, err) } assert.Equal(t, tt.wantKeys, ks) assert.False(t, cfg.entries.IsModified()) }) } } func TestRemove(t *testing.T) { tests := []struct { name string keys []string wantErr bool wantErrMsg string }{ { name: "remove top level key", keys: []string{"pager"}, }, { name: "remove nested key", keys: []string{"nested", "pager"}, }, { name: "remove top level map", keys: []string{"nested"}, }, { name: "remove non-existant top level key", keys: []string{"unknown"}, wantErr: true, wantErrMsg: `could not find key "unknown"`, }, { name: "remove non-existant nested key", keys: []string{"nested", "invalid"}, wantErr: true, wantErrMsg: `could not find key "invalid"`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := testConfig() err := cfg.Remove(tt.keys) if tt.wantErr { assert.EqualError(t, err, tt.wantErrMsg) assert.False(t, cfg.entries.IsModified()) } else { assert.NoError(t, err) assert.True(t, cfg.entries.IsModified()) } _, getErr := cfg.Get(tt.keys) assert.Error(t, getErr) }) } } func TestSet(t *testing.T) { tests := []struct { name string keys []string value string }{ { name: "set top level existing key", keys: []string{"pager"}, value: "test pager", }, { name: "set nested existing key", keys: []string{"nested", "pager"}, value: "new pager", }, { name: "set top level map", keys: []string{"nested"}, value: "override", }, { name: "set non-existant top level key", keys: []string{"unknown"}, value: "why not", }, { name: "set non-existant nested key", keys: []string{"nested", "invalid"}, value: "sure", }, { name: "set non-existant nest", keys: []string{"johnny", "test"}, value: "dukey", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := testConfig() cfg.Set(tt.keys, tt.value) assert.True(t, cfg.entries.IsModified()) value, err := cfg.Get(tt.keys) assert.NoError(t, err) assert.Equal(t, tt.value, value) }) } } func TestDefaultGeneralEntries(t *testing.T) { cfg := ReadFromString(defaultGeneralEntries) protocol, err := cfg.Get([]string{"git_protocol"}) assert.NoError(t, err) assert.Equal(t, "https", protocol) editor, err := cfg.Get([]string{"editor"}) assert.NoError(t, err) assert.Equal(t, "", editor) prompt, err := cfg.Get([]string{"prompt"}) assert.NoError(t, err) assert.Equal(t, "enabled", prompt) pager, err := cfg.Get([]string{"pager"}) assert.NoError(t, err) assert.Equal(t, "", pager) socket, err := cfg.Get([]string{"http_unix_socket"}) assert.NoError(t, err) assert.Equal(t, "", socket) browser, err := cfg.Get([]string{"browser"}) assert.NoError(t, err) assert.Equal(t, "", browser) unknown, err := cfg.Get([]string{"unknown"}) assert.EqualError(t, err, `could not find key "unknown"`) assert.Equal(t, "", unknown) } func testConfig() *Config { var data = ` git_protocol: ssh editor: prompt: enabled pager: less nested: key: value pager: more ` return ReadFromString(data) } func testGlobalData() string { var data = ` git_protocol: ssh editor: prompt: enabled pager: less ` return data } func testHostsData() string { var data = ` github.com: user: user1 oauth_token: xxxxxxxxxxxxxxxxxxxx git_protocol: ssh enterprise.com: user: user2 oauth_token: yyyyyyyyyyyyyyyyyyyy git_protocol: https ` return data } func testFullConfig() string { var data = ` git_protocol: ssh editor: prompt: enabled pager: less hosts: github.com: user: user1 oauth_token: xxxxxxxxxxxxxxxxxxxx git_protocol: ssh enterprise.com: user: user2 oauth_token: yyyyyyyyyyyyyyyyyyyy git_protocol: https ` return data } go-gh-1.2.1/pkg/config/errors.go000066400000000000000000000013771437723465500164350ustar00rootroot00000000000000package config import ( "fmt" ) // InvalidConfigFileError represents an error when trying to read a config file. type InvalidConfigFileError struct { Path string Err error } // Allow InvalidConfigFileError to satisfy error interface. 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 { return e.Err } // KeyNotFoundError represents an error when trying to find a config key // that does not exist. type KeyNotFoundError struct { Key string } // Allow KeyNotFoundError to satisfy error interface. func (e KeyNotFoundError) Error() string { return fmt.Sprintf("could not find key %q", e.Key) } go-gh-1.2.1/pkg/jq/000077500000000000000000000000001437723465500137275ustar00rootroot00000000000000go-gh-1.2.1/pkg/jq/jq.go000066400000000000000000000032261437723465500146730ustar00rootroot00000000000000// Package jq facilitates processing of JSON strings using jq expressions. package jq import ( "encoding/json" "fmt" "io" "math" "os" "strconv" "github.com/itchyny/gojq" ) // Evaluate a jq expression against an input and write it to an output. func Evaluate(input io.Reader, output io.Writer, expr string) error { query, err := gojq.Parse(expr) if err != nil { return err } code, err := gojq.Compile( query, gojq.WithEnvironLoader(func() []string { return os.Environ() })) if err != nil { return err } jsonData, err := io.ReadAll(input) if err != nil { return err } var responseData interface{} err = json.Unmarshal(jsonData, &responseData) if err != nil { return err } enc := json.NewEncoder(output) iter := code.Run(responseData) for { v, ok := iter.Next() if !ok { break } if err, isErr := v.(error); isErr { return err } if text, e := jsonScalarToString(v); e == nil { _, err := fmt.Fprintln(output, text) if err != nil { return err } } else if tt, ok := v.([]interface{}); ok && tt == nil { _, err = fmt.Fprint(output, "[]\n") if err != nil { return err } } else { if err = enc.Encode(v); err != nil { return err } } } return nil } func jsonScalarToString(input interface{}) (string, error) { switch tt := input.(type) { case string: return tt, nil case float64: if math.Trunc(tt) == tt { return strconv.FormatFloat(tt, 'f', 0, 64), nil } else { return strconv.FormatFloat(tt, 'f', 2, 64), nil } case nil: return "", nil case bool: return fmt.Sprintf("%v", tt), nil default: return "", fmt.Errorf("cannot convert type to string: %v", tt) } } go-gh-1.2.1/pkg/jq/jq_test.go000066400000000000000000000044651437723465500157400ustar00rootroot00000000000000package jq import ( "bytes" "io" "strings" "testing" "github.com/MakeNowJust/heredoc" "github.com/stretchr/testify/assert" ) func TestEvaluate(t *testing.T) { t.Setenv("CODE", "code_c") type args struct { json io.Reader expr string } tests := []struct { name string args args wantW string wantErr bool }{ { name: "simple", args: args{ json: strings.NewReader(`{"name":"Mona", "arms":8}`), expr: `.name`, }, wantW: "Mona\n", }, { name: "multiple queries", args: args{ json: strings.NewReader(`{"name":"Mona", "arms":8}`), expr: `.name,.arms`, }, wantW: "Mona\n8\n", }, { name: "object as JSON", args: args{ json: strings.NewReader(`{"user":{"login":"monalisa"}}`), expr: `.user`, }, wantW: "{\"login\":\"monalisa\"}\n", }, { name: "empty array", args: args{ json: strings.NewReader(`[]`), expr: `.`, }, wantW: "[]\n", }, { name: "empty array 2", args: args{ json: strings.NewReader(`[]`), expr: `[]`, }, wantW: "[]\n", }, { name: "complex", args: args{ json: strings.NewReader(heredoc.Doc(`[ { "title": "First title", "labels": [{"name":"bug"}, {"name":"help wanted"}] }, { "title": "Second but not last", "labels": [] }, { "title": "Alas, tis' the end", "labels": [{}, {"name":"feature"}] } ]`)), expr: `.[] | [.title,(.labels | map(.name) | join(","))] | @tsv`, }, wantW: heredoc.Doc(` First title bug,help wanted Second but not last Alas, tis' the end ,feature `), }, { name: "with env var", args: args{ json: strings.NewReader(heredoc.Doc(`[ { "title": "code_a", "labels": [{"name":"bug"}, {"name":"help wanted"}] }, { "title": "code_b", "labels": [] }, { "title": "code_c", "labels": [{}, {"name":"feature"}] } ]`)), expr: `.[] | select(.title == env.CODE) | .labels`, }, wantW: "[{},{\"name\":\"feature\"}]\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := &bytes.Buffer{} err := Evaluate(tt.args.json, w, tt.args.expr) if tt.wantErr { assert.Error(t, err) return } assert.NoError(t, err) assert.Equal(t, tt.wantW, w.String()) }) } } go-gh-1.2.1/pkg/jsonpretty/000077500000000000000000000000001437723465500155365ustar00rootroot00000000000000go-gh-1.2.1/pkg/jsonpretty/format.go000066400000000000000000000056311437723465500173620ustar00rootroot00000000000000// Package jsonpretty implements a terminal pretty-printer for JSON. package jsonpretty import ( "bytes" "encoding/json" "fmt" "io" "strings" ) const ( colorDelim = "\x1b[1;38m" // bright white colorKey = "\x1b[1;34m" // bright blue colorNull = "\x1b[36m" // cyan colorString = "\x1b[32m" // green colorBool = "\x1b[33m" // yellow colorReset = "\x1b[m" ) // Format reads JSON from r and writes a prettified version of it to w. func Format(w io.Writer, r io.Reader, indent string, colorize bool) error { dec := json.NewDecoder(r) dec.UseNumber() c := func(ansi string) string { if !colorize { return "" } return ansi } var idx int var stack []json.Delim for { t, err := dec.Token() if err == io.EOF { break } if err != nil { return err } switch tt := t.(type) { case json.Delim: switch tt { case '{', '[': stack = append(stack, tt) idx = 0 if _, err := fmt.Fprint(w, c(colorDelim), tt, c(colorReset)); err != nil { return err } if dec.More() { if _, err := fmt.Fprint(w, "\n", strings.Repeat(indent, len(stack))); err != nil { return err } } continue case '}', ']': stack = stack[:len(stack)-1] idx = 0 if _, err := fmt.Fprint(w, c(colorDelim), tt, c(colorReset)); err != nil { return err } } default: b, err := marshalJSON(tt) if err != nil { return err } isKey := len(stack) > 0 && stack[len(stack)-1] == '{' && idx%2 == 0 idx++ var color string if isKey { color = colorKey } else if tt == nil { color = colorNull } else { switch t.(type) { case string: color = colorString case bool: color = colorBool } } if color != "" { if _, err := fmt.Fprint(w, c(color)); err != nil { return err } } if _, err := w.Write(b); err != nil { return err } if color != "" { if _, err := fmt.Fprint(w, c(colorReset)); err != nil { return err } } if isKey { if _, err := fmt.Fprint(w, c(colorDelim), ":", c(colorReset), " "); err != nil { return err } continue } } if dec.More() { if _, err := fmt.Fprint(w, c(colorDelim), ",", c(colorReset), "\n", strings.Repeat(indent, len(stack))); err != nil { return err } } else if len(stack) > 0 { if _, err := fmt.Fprint(w, "\n", strings.Repeat(indent, len(stack)-1)); err != nil { return err } } else { if _, err := fmt.Fprint(w, "\n"); err != nil { return err } } } return nil } // marshalJSON works like json.Marshal, but with HTML-escaping disabled. func marshalJSON(v interface{}) ([]byte, error) { buf := bytes.Buffer{} enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { return nil, err } bb := buf.Bytes() // omit trailing newline added by json.Encoder if len(bb) > 0 && bb[len(bb)-1] == '\n' { return bb[:len(bb)-1], nil } return bb, nil } go-gh-1.2.1/pkg/jsonpretty/format_test.go000066400000000000000000000043171437723465500204210ustar00rootroot00000000000000package jsonpretty import ( "bytes" "io" "testing" ) func TestWrite(t *testing.T) { type args struct { r io.Reader indent string colorize bool } tests := []struct { name string args args wantW string wantErr bool }{ { name: "blank", args: args{ r: bytes.NewBufferString(``), indent: "", colorize: true, }, wantW: "", wantErr: false, }, { name: "empty object", args: args{ r: bytes.NewBufferString(`{}`), indent: "", colorize: true, }, wantW: "\x1b[1;38m{\x1b[m\x1b[1;38m}\x1b[m\n", wantErr: false, }, { name: "nested object", args: args{ r: bytes.NewBufferString(`{"hash":{"a":1,"b":2},"array":[3,4]}`), indent: "\t", colorize: true, }, wantW: "\x1b[1;38m{\x1b[m\n\t\x1b[1;34m\"hash\"\x1b[m\x1b[1;38m:\x1b[m " + "\x1b[1;38m{\x1b[m\n\t\t\x1b[1;34m\"a\"\x1b[m\x1b[1;38m:\x1b[m 1\x1b[1;38m,\x1b[m\n\t\t\x1b[1;34m\"b\"\x1b[m\x1b[1;38m:\x1b[m 2\n\t\x1b[1;38m}\x1b[m\x1b[1;38m,\x1b[m" + "\n\t\x1b[1;34m\"array\"\x1b[m\x1b[1;38m:\x1b[m \x1b[1;38m[\x1b[m\n\t\t3\x1b[1;38m,\x1b[m\n\t\t4\n\t\x1b[1;38m]\x1b[m\n\x1b[1;38m}\x1b[m\n", wantErr: false, }, { name: "no color", args: args{ r: bytes.NewBufferString(`{"hash":{"a":1,"b":2},"array":[3,4]}`), indent: "\t", colorize: false, }, wantW: "{\n\t\"hash\": {\n\t\t\"a\": 1,\n\t\t\"b\": 2\n\t},\n\t\"array\": [\n\t\t3,\n\t\t4\n\t]\n}\n", wantErr: false, }, { name: "string", args: args{ r: bytes.NewBufferString(`"foo"`), indent: "", colorize: true, }, wantW: "\x1b[32m\"foo\"\x1b[m\n", wantErr: false, }, { name: "error", args: args{ r: bytes.NewBufferString(`{{`), indent: "", colorize: true, }, wantW: "\x1b[1;38m{\x1b[m\n", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := &bytes.Buffer{} if err := Format(w, tt.args.r, tt.args.indent, tt.args.colorize); (err != nil) != tt.wantErr { t.Errorf("Write() error = %v, wantErr %v", err, tt.wantErr) return } if w.String() != tt.wantW { t.Errorf("got: %q, want: %q", w.String(), tt.wantW) } }) } } go-gh-1.2.1/pkg/markdown/000077500000000000000000000000001437723465500151375ustar00rootroot00000000000000go-gh-1.2.1/pkg/markdown/markdown.go000066400000000000000000000036551437723465500173210ustar00rootroot00000000000000// Package markdown facilitates rendering markdown in the terminal. package markdown import ( "os" "strings" "github.com/charmbracelet/glamour" ) // WithoutIndentation is a rendering option that removes indentation from the markdown rendering. func WithoutIndentation() glamour.TermRendererOption { overrides := []byte(` { "document": { "margin": 0 }, "code_block": { "margin": 0 } }`) return glamour.WithStylesFromJSONBytes(overrides) } // WithoutWrap is a rendering option that set the character limit for soft wraping the markdown rendering. func WithWrap(w int) glamour.TermRendererOption { return glamour.WithWordWrap(w) } // WithTheme is a rendering option that sets the theme to use while rendering the markdown. // It can be used in conjunction with [term.Theme]. // If the enviornment variable GLAMOUR_STYLE is set, it will take precedence over the provided theme. func WithTheme(theme string) glamour.TermRendererOption { style := os.Getenv("GLAMOUR_STYLE") if style == "" || style == "auto" { switch theme { case "light", "dark": style = theme default: style = "notty" } } return glamour.WithStylePath(style) } // WithBaseURL is a rendering option that sets the base URL to use when rendering relative URLs. func WithBaseURL(u string) glamour.TermRendererOption { return glamour.WithBaseURL(u) } // Render the markdown string according to the specified rendering options. // By default emoji are rendered and new lines are preserved. func Render(text string, opts ...glamour.TermRendererOption) (string, error) { // Glamour rendering preserves carriage return characters in code blocks, but // we need to ensure that no such characters are present in the output. text = strings.ReplaceAll(text, "\r\n", "\n") opts = append(opts, glamour.WithEmoji(), glamour.WithPreservedNewLines()) tr, err := glamour.NewTermRenderer(opts...) if err != nil { return "", err } return tr.Render(text) } go-gh-1.2.1/pkg/markdown/markdown_test.go000066400000000000000000000011221437723465500203430ustar00rootroot00000000000000package markdown import ( "testing" "github.com/stretchr/testify/assert" ) func Test_Render(t *testing.T) { t.Setenv("GLAMOUR_STYLE", "") tests := []struct { name string text string theme string }{ { name: "light style", text: "some text", theme: "light", }, { name: "dark style", text: "some text", theme: "dark", }, { name: "notty style", text: "some text", theme: "none", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, err := Render(tt.text, WithTheme(tt.theme)) assert.NoError(t, err) }) } } go-gh-1.2.1/pkg/repository/000077500000000000000000000000001437723465500155345ustar00rootroot00000000000000go-gh-1.2.1/pkg/repository/repository.go000066400000000000000000000043061437723465500203050ustar00rootroot00000000000000// Package repository is a set of types and functions for modeling and // interacting with GitHub repositories. package repository import ( "fmt" "strings" "github.com/cli/go-gh/internal/git" irepo "github.com/cli/go-gh/internal/repository" "github.com/cli/go-gh/pkg/auth" ) // Repository is the interface that wraps repository information methods. type Repository interface { 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) { if git.IsURL(s) { u, err := git.ParseURL(s) if err != nil { return nil, err } host, owner, name, err := git.RepoInfoFromURL(u) if err != nil { return nil, err } return irepo.New(host, owner, name), 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) } } switch len(parts) { case 3: return irepo.New(parts[0], parts[1], parts[2]), nil case 2: host, _ := auth.DefaultHost() return irepo.New(host, parts[0], parts[1]), nil default: return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, s) } } // 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 host provided. func ParseWithHost(s, host string) (Repository, error) { if git.IsURL(s) { u, err := git.ParseURL(s) if err != nil { return nil, err } host, owner, name, err := git.RepoInfoFromURL(u) if err != nil { return nil, err } return irepo.New(host, owner, name), 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) } } switch len(parts) { case 3: return irepo.New(parts[0], parts[1], parts[2]), nil case 2: return irepo.New(host, parts[0], parts[1]), nil default: return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, s) } } go-gh-1.2.1/pkg/repository/repository_test.go000066400000000000000000000107601437723465500213450ustar00rootroot00000000000000package repository import ( "testing" "github.com/cli/go-gh/pkg/config" "github.com/stretchr/testify/assert" ) func TestParse(t *testing.T) { stubConfig(t, "") tests := []struct { name string input string hostOverride string wantOwner string wantName string wantHost string wantErr string }{ { name: "OWNER/REPO combo", input: "OWNER/REPO", wantHost: "github.com", wantOwner: "OWNER", wantName: "REPO", }, { name: "too few elements", input: "OWNER", wantErr: `expected the "[HOST/]OWNER/REPO" format, got "OWNER"`, }, { name: "too many elements", input: "a/b/c/d", wantErr: `expected the "[HOST/]OWNER/REPO" format, got "a/b/c/d"`, }, { name: "blank value", input: "a/", wantErr: `expected the "[HOST/]OWNER/REPO" format, got "a/"`, }, { name: "with hostname", input: "example.org/OWNER/REPO", wantHost: "example.org", wantOwner: "OWNER", wantName: "REPO", }, { name: "full URL", input: "https://example.org/OWNER/REPO.git", wantHost: "example.org", wantOwner: "OWNER", wantName: "REPO", }, { name: "SSH URL", input: "git@example.org:OWNER/REPO.git", wantHost: "example.org", wantOwner: "OWNER", wantName: "REPO", }, { name: "OWNER/REPO with default host override", input: "OWNER/REPO", hostOverride: "override.com", wantHost: "override.com", wantOwner: "OWNER", wantName: "REPO", }, { name: "HOST/OWNER/REPO with default host override", input: "example.com/OWNER/REPO", hostOverride: "override.com", wantHost: "example.com", wantOwner: "OWNER", wantName: "REPO", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv("GH_CONFIG_DIR", "nonexistant") if tt.hostOverride != "" { t.Setenv("GH_HOST", tt.hostOverride) } r, err := Parse(tt.input) if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) 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()) }) } } func TestParse_hostFromConfig(t *testing.T) { var cfgStr = ` hosts: enterprise.com: user: user2 oauth_token: yyyyyyyyyyyyyyyyyyyy git_protocol: https ` 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()) } func TestParseWithHost(t *testing.T) { tests := []struct { name string input string host string wantOwner string wantName string wantHost string wantErr string }{ { name: "OWNER/REPO combo", input: "OWNER/REPO", host: "github.com", wantHost: "github.com", wantOwner: "OWNER", wantName: "REPO", }, { name: "too few elements", input: "OWNER", host: "github.com", wantErr: `expected the "[HOST/]OWNER/REPO" format, got "OWNER"`, }, { name: "too many elements", input: "a/b/c/d", host: "github.com", wantErr: `expected the "[HOST/]OWNER/REPO" format, got "a/b/c/d"`, }, { name: "blank value", input: "a/", host: "github.com", wantErr: `expected the "[HOST/]OWNER/REPO" format, got "a/"`, }, { name: "with hostname", input: "example.org/OWNER/REPO", host: "github.com", wantHost: "example.org", wantOwner: "OWNER", wantName: "REPO", }, { name: "full URL", input: "https://example.org/OWNER/REPO.git", host: "github.com", wantHost: "example.org", wantOwner: "OWNER", wantName: "REPO", }, { name: "SSH URL", input: "git@example.org:OWNER/REPO.git", host: "github.com", wantHost: "example.org", wantOwner: "OWNER", wantName: "REPO", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { r, err := ParseWithHost(tt.input, tt.host) if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) 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()) }) } } 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 }) } go-gh-1.2.1/pkg/ssh/000077500000000000000000000000001437723465500141125ustar00rootroot00000000000000go-gh-1.2.1/pkg/ssh/ssh.go000066400000000000000000000042221437723465500152360ustar00rootroot00000000000000// Package ssh resolves local SSH hostname aliases. package ssh import ( "bufio" "net/url" "os/exec" "strings" "sync" "github.com/cli/safeexec" ) type Translator struct { cacheMap map[string]string cacheMu sync.RWMutex sshPath string sshPathErr error sshPathMu sync.Mutex lookPath func(string) (string, error) newCommand func(string, ...string) *exec.Cmd } // NewTranslator initializes a new Translator instance. func NewTranslator() *Translator { return &Translator{} } // Translate applies applicable SSH hostname aliases to the specified URL and returns the resulting URL. func (t *Translator) Translate(u *url.URL) *url.URL { if u.Scheme != "ssh" { return u } resolvedHost, err := t.resolve(u.Hostname()) if err != nil { return u } if strings.EqualFold(resolvedHost, "ssh.github.com") { resolvedHost = "github.com" } newURL, _ := url.Parse(u.String()) newURL.Host = resolvedHost return newURL } func (t *Translator) resolve(hostname string) (string, error) { t.cacheMu.RLock() cached, cacheFound := t.cacheMap[strings.ToLower(hostname)] t.cacheMu.RUnlock() if cacheFound { return cached, nil } var sshPath string t.sshPathMu.Lock() if t.sshPath == "" && t.sshPathErr == nil { lookPath := t.lookPath if lookPath == nil { lookPath = safeexec.LookPath } t.sshPath, t.sshPathErr = lookPath("ssh") } if t.sshPathErr != nil { defer t.sshPathMu.Unlock() return t.sshPath, t.sshPathErr } sshPath = t.sshPath t.sshPathMu.Unlock() t.cacheMu.Lock() defer t.cacheMu.Unlock() newCommand := t.newCommand if newCommand == nil { newCommand = exec.Command } sshCmd := newCommand(sshPath, "-G", hostname) stdout, err := sshCmd.StdoutPipe() if err != nil { return "", err } if err := sshCmd.Start(); err != nil { return "", err } var resolvedHost string s := bufio.NewScanner(stdout) for s.Scan() { line := s.Text() parts := strings.SplitN(line, " ", 2) if len(parts) == 2 && parts[0] == "hostname" { resolvedHost = parts[1] } } _ = sshCmd.Wait() if t.cacheMap == nil { t.cacheMap = map[string]string{} } t.cacheMap[strings.ToLower(hostname)] = resolvedHost return resolvedHost, nil } go-gh-1.2.1/pkg/ssh/ssh_test.go000066400000000000000000000065671437723465500163130ustar00rootroot00000000000000package ssh import ( "fmt" "net/url" "os" "os/exec" "testing" "github.com/MakeNowJust/heredoc" "github.com/cli/safeexec" ) func TestTranslator(t *testing.T) { if _, err := safeexec.LookPath("ssh"); err != nil { t.Skip("no ssh found on system") } tests := []struct { name string sshConfig string arg string want string }{ { name: "translate SSH URL", sshConfig: heredoc.Doc(` Host github-* Hostname github.com `), arg: "ssh://git@github-foo/owner/repo.git", want: "ssh://git@github.com/owner/repo.git", }, { name: "does not translate HTTPS URL", sshConfig: heredoc.Doc(` Host github-* Hostname github.com `), arg: "https://github-foo/owner/repo.git", want: "https://github-foo/owner/repo.git", }, { name: "treats ssh.github.com as github.com", sshConfig: heredoc.Doc(` Host github.com Hostname ssh.github.com `), arg: "ssh://git@github.com/owner/repo.git", want: "ssh://git@github.com/owner/repo.git", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { f, err := os.CreateTemp("", "ssh-config.*") if err != nil { t.Fatalf("error creating file: %v", err) } _, err = f.WriteString(tt.sshConfig) _ = f.Close() if err != nil { t.Fatalf("error writing ssh config: %v", err) } tr := &Translator{ newCommand: func(exe string, args ...string) *exec.Cmd { args = append([]string{"-F", f.Name()}, args...) return exec.Command(exe, args...) }, } u, err := url.Parse(tt.arg) if err != nil { t.Fatalf("error parsing URL: %v", err) } res := tr.Translate(u) if got := res.String(); got != tt.want { t.Errorf("expected %q, got %q", tt.want, got) } }) } } func TestHelperProcess(t *testing.T) { if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" { return } if err := func(args []string) error { fmt.Fprint(os.Stdout, "hostname github.com\n") return nil }(os.Args[3:]); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } os.Exit(0) } func TestTranslator_caching(t *testing.T) { countLookPath := 0 countNewCommand := 0 tr := &Translator{ lookPath: func(s string) (string, error) { countLookPath++ return "/path/to/ssh", nil }, newCommand: func(exe string, args ...string) *exec.Cmd { args = append([]string{"-test.run=TestHelperProcess", "--", exe}, args...) c := exec.Command(os.Args[0], args...) c.Env = []string{"GH_WANT_HELPER_PROCESS=1"} countNewCommand++ return c }, } u1, err := url.Parse("ssh://github1.com/owner/repo.git") if err != nil { t.Fatalf("error parsing URL: %v", err) } if res := tr.Translate(u1); res.Host != "github.com" { t.Errorf("expected github.com, got: %q", res.Host) } if res := tr.Translate(u1); res.Host != "github.com" { t.Errorf("expected github.com, got: %q", res.Host) } u2, err := url.Parse("ssh://github2.com/owner/repo.git") if err != nil { t.Fatalf("error parsing URL: %v", err) } if res := tr.Translate(u2); res.Host != "github.com" { t.Errorf("expected github.com, got: %q", res.Host) } if res := tr.Translate(u2); res.Host != "github.com" { t.Errorf("expected github.com, got: %q", res.Host) } if countLookPath != 1 { t.Errorf("expected lookPath to happen 1 time; actual: %d", countLookPath) } if countNewCommand != 2 { t.Errorf("expected ssh command to shell out 2 times; actual: %d", countNewCommand) } } go-gh-1.2.1/pkg/tableprinter/000077500000000000000000000000001437723465500160105ustar00rootroot00000000000000go-gh-1.2.1/pkg/tableprinter/table.go000066400000000000000000000127651437723465500174410ustar00rootroot00000000000000// Package tableprinter facilitates rendering column-formatted data to a terminal and TSV-formatted data to // a script or a file. It is suitable for presenting tabular data in a human-readable format that is // guaranteed to fit within the given viewport, while at the same time offering the same data in a // machine-readable format for scripts. package tableprinter import ( "fmt" "io" "strings" "github.com/cli/go-gh/pkg/text" ) type fieldOption func(*tableField) type TablePrinter interface { AddField(string, ...fieldOption) EndRow() Render() error } // WithTruncate overrides the truncation function for the field. The function should transform a string // argument into a string that fits within the given display width. The default behavior is to truncate the // value by adding "..." in the end. Pass nil to disable truncation for this value. func WithTruncate(fn func(int, string) string) fieldOption { return func(f *tableField) { f.truncateFunc = fn } } // WithColor sets the color function for the field. The function should transform a string value by wrapping // it in ANSI escape codes. The color function will not be used if the table was initialized in non-terminal mode. func WithColor(fn func(string) string) fieldOption { return func(f *tableField) { f.colorFunc = fn } } // New initializes a table printer with terminal mode and terminal width. When terminal mode is enabled, the // output will be human-readable, column-formatted to fit available width, and rendered with color support. // In non-terminal mode, the output is tab-separated and all truncation of values is disabled. func New(w io.Writer, isTTY bool, maxWidth int) TablePrinter { if isTTY { return &ttyTablePrinter{ out: w, maxWidth: maxWidth, } } return &tsvTablePrinter{ out: w, } } type tableField struct { text string truncateFunc func(int, string) string colorFunc func(string) string } type ttyTablePrinter struct { out io.Writer maxWidth int rows [][]tableField } func (t *ttyTablePrinter) AddField(s string, opts ...fieldOption) { if t.rows == nil { t.rows = make([][]tableField, 1) } rowI := len(t.rows) - 1 field := tableField{ text: s, truncateFunc: text.Truncate, } for _, opt := range opts { opt(&field) } t.rows[rowI] = append(t.rows[rowI], field) } func (t *ttyTablePrinter) EndRow() { t.rows = append(t.rows, []tableField{}) } func (t *ttyTablePrinter) Render() error { if len(t.rows) == 0 { return nil } delim := " " numCols := len(t.rows[0]) colWidths := t.calculateColumnWidths(len(delim)) for _, row := range t.rows { for col, field := range row { if col > 0 { _, err := fmt.Fprint(t.out, delim) if err != nil { return err } } truncVal := field.text if field.truncateFunc != nil { truncVal = field.truncateFunc(colWidths[col], field.text) } if col < numCols-1 { // pad value with spaces on the right if padWidth := colWidths[col] - text.DisplayWidth(field.text); padWidth > 0 { truncVal += strings.Repeat(" ", padWidth) } } if field.colorFunc != nil { truncVal = field.colorFunc(truncVal) } _, err := fmt.Fprint(t.out, truncVal) if err != nil { return err } } if len(row) > 0 { _, err := fmt.Fprint(t.out, "\n") if err != nil { return err } } } return nil } func (t *ttyTablePrinter) calculateColumnWidths(delimSize int) []int { numCols := len(t.rows[0]) maxColWidths := make([]int, numCols) colWidths := make([]int, numCols) for _, row := range t.rows { for col, field := range row { w := text.DisplayWidth(field.text) if w > maxColWidths[col] { maxColWidths[col] = w } // if this field has disabled truncating, ensure that the column is wide enough if field.truncateFunc == nil && w > colWidths[col] { colWidths[col] = w } } } availWidth := func() int { setWidths := 0 for col := 0; col < numCols; col++ { setWidths += colWidths[col] } return t.maxWidth - delimSize*(numCols-1) - setWidths } numFixedCols := func() int { fixedCols := 0 for col := 0; col < numCols; col++ { if colWidths[col] > 0 { fixedCols++ } } return fixedCols } // set the widths of short columns if w := availWidth(); w > 0 { if numFlexColumns := numCols - numFixedCols(); numFlexColumns > 0 { perColumn := w / numFlexColumns for col := 0; col < numCols; col++ { if max := maxColWidths[col]; max < perColumn { colWidths[col] = max } } } } // truncate long columns to the remaining available width if numFlexColumns := numCols - numFixedCols(); numFlexColumns > 0 { perColumn := availWidth() / numFlexColumns for col := 0; col < numCols; col++ { if colWidths[col] == 0 { if max := maxColWidths[col]; max < perColumn { colWidths[col] = max } else if perColumn > 0 { colWidths[col] = perColumn } } } } // add the remainder to truncated columns if w := availWidth(); w > 0 { for col := 0; col < numCols; col++ { d := maxColWidths[col] - colWidths[col] toAdd := w if d < toAdd { toAdd = d } colWidths[col] += toAdd w -= toAdd if w <= 0 { break } } } return colWidths } type tsvTablePrinter struct { out io.Writer currentCol int } func (t *tsvTablePrinter) AddField(text string, opts ...fieldOption) { if t.currentCol > 0 { fmt.Fprint(t.out, "\t") } fmt.Fprint(t.out, text) t.currentCol++ } func (t *tsvTablePrinter) EndRow() { fmt.Fprint(t.out, "\n") t.currentCol = 0 } func (t *tsvTablePrinter) Render() error { return nil } go-gh-1.2.1/pkg/tableprinter/table_test.go000066400000000000000000000036121437723465500204670ustar00rootroot00000000000000package tableprinter import ( "bytes" "log" "os" "testing" ) func ExampleTablePrinter() { // information about the terminal can be obtained using the [pkg/term] package isTTY := true termWidth := 14 red := func(s string) string { return "\x1b[31m" + s + "\x1b[m" } t := New(os.Stdout, isTTY, termWidth) t.AddField("9", WithTruncate(nil)) t.AddField("hello") t.EndRow() t.AddField("10", WithTruncate(nil)) t.AddField("long description", WithColor(red)) t.EndRow() if err := t.Render(); err != nil { log.Fatal(err) } // stdout now contains: // 9 hello // 10 long de... } func Test_ttyTablePrinter_autoTruncate(t *testing.T) { buf := bytes.Buffer{} tp := New(&buf, true, 5) tp.AddField("1") tp.AddField("hello") tp.EndRow() tp.AddField("2") tp.AddField("world") tp.EndRow() err := tp.Render() if err != nil { t.Fatalf("unexpected error: %v", err) } expected := "1 he\n2 wo\n" if buf.String() != expected { t.Errorf("expected: %q, got: %q", expected, buf.String()) } } func Test_ttyTablePrinter_WithTruncate(t *testing.T) { buf := bytes.Buffer{} tp := New(&buf, true, 15) tp.AddField("long SHA", WithTruncate(nil)) tp.AddField("hello") tp.EndRow() tp.AddField("another SHA", WithTruncate(nil)) tp.AddField("world") tp.EndRow() err := tp.Render() if err != nil { t.Fatalf("unexpected error: %v", err) } expected := "long SHA he\nanother SHA wo\n" if buf.String() != expected { t.Errorf("expected: %q, got: %q", expected, buf.String()) } } func Test_tsvTablePrinter(t *testing.T) { buf := bytes.Buffer{} tp := New(&buf, false, 0) tp.AddField("1") tp.AddField("hello") tp.EndRow() tp.AddField("2") tp.AddField("world") tp.EndRow() err := tp.Render() if err != nil { t.Fatalf("unexpected error: %v", err) } expected := "1\thello\n2\tworld\n" if buf.String() != expected { t.Errorf("expected: %q, got: %q", expected, buf.String()) } } go-gh-1.2.1/pkg/template/000077500000000000000000000000001437723465500151305ustar00rootroot00000000000000go-gh-1.2.1/pkg/template/template.go000066400000000000000000000152361437723465500173010ustar00rootroot00000000000000// Package template facilitates processing of JSON strings using Go templates. // Provides additional functions not available using basic Go templates, such as coloring, // and table rendering. package template import ( "encoding/json" "fmt" "io" "math" "strconv" "strings" "text/template" "time" "github.com/cli/go-gh/pkg/tableprinter" "github.com/cli/go-gh/pkg/text" color "github.com/mgutz/ansi" ) const ( ellipsis = "..." ) // Template is the representation of a template. type Template struct { colorEnabled bool output io.Writer tmpl *template.Template tp tableprinter.TablePrinter width int funcs template.FuncMap } // New initializes a Template. func New(w io.Writer, width int, colorEnabled bool) Template { return Template{ colorEnabled: colorEnabled, output: w, tp: tableprinter.New(w, true, width), width: width, funcs: template.FuncMap{}, } } // Funcs adds the elements of the argument map to the template's function map. // It must be called before the template is parsed. // It is legal to overwrite elements of the map including default functions. // The return value is the template, so calls can be chained. func (t *Template) Funcs(funcMap map[string]interface{}) *Template { for name, f := range funcMap { t.funcs[name] = f } return t } // Parse the given template string for use with Execute. func (t *Template) Parse(tmpl string) error { now := time.Now() templateFuncs := map[string]interface{}{ "autocolor": colorFunc, "color": colorFunc, "hyperlink": hyperlinkFunc, "join": joinFunc, "pluck": pluckFunc, "tablerender": func() (string, error) { // After rendering a table, prepare a new table printer incase user wants to output // another table. defer func() { t.tp = tableprinter.New(t.output, true, t.width) }() return tableRenderFunc(t.tp) }, "tablerow": func(fields ...interface{}) (string, error) { return tableRowFunc(t.tp, fields...) }, "timeago": func(input string) (string, error) { return timeAgoFunc(now, input) }, "timefmt": timeFormatFunc, "truncate": truncateFunc, } if !t.colorEnabled { templateFuncs["autocolor"] = autoColorFunc } for name, f := range t.funcs { templateFuncs[name] = f } var err error t.tmpl, err = template.New("").Funcs(templateFuncs).Parse(tmpl) return err } // Execute applies the parsed template to the input and writes result to the writer // the template was initialized with. func (t *Template) Execute(input io.Reader) error { jsonData, err := io.ReadAll(input) if err != nil { return err } var data interface{} if err := json.Unmarshal(jsonData, &data); err != nil { return err } return t.tmpl.Execute(t.output, data) } // Flush writes any remaining data to the writer. This is mostly useful // when a templates uses the tablerow function but does not include the // tablerender function at the end. // If a template did not use the table functionality this is a noop. func (t *Template) Flush() error { if _, err := tableRenderFunc(t.tp); err != nil { return err } return nil } func colorFunc(colorName string, input interface{}) (string, error) { text, err := jsonScalarToString(input) if err != nil { return "", err } return color.Color(text, colorName), nil } func pluckFunc(field string, input []interface{}) []interface{} { var results []interface{} for _, item := range input { obj := item.(map[string]interface{}) results = append(results, obj[field]) } return results } func joinFunc(sep string, input []interface{}) (string, error) { var results []string for _, item := range input { text, err := jsonScalarToString(item) if err != nil { return "", err } results = append(results, text) } return strings.Join(results, sep), nil } func timeFormatFunc(format, input string) (string, error) { t, err := time.Parse(time.RFC3339, input) if err != nil { return "", err } return t.Format(format), nil } func timeAgoFunc(now time.Time, input string) (string, error) { t, err := time.Parse(time.RFC3339, input) if err != nil { return "", err } return timeAgo(now.Sub(t)), nil } func truncateFunc(maxWidth int, v interface{}) (string, error) { if v == nil { return "", nil } if s, ok := v.(string); ok { return text.Truncate(maxWidth, s), nil } return "", fmt.Errorf("invalid value; expected string, got %T", v) } func autoColorFunc(colorName string, input interface{}) (string, error) { return jsonScalarToString(input) } func tableRowFunc(tp tableprinter.TablePrinter, fields ...interface{}) (string, error) { if tp == nil { return "", fmt.Errorf("failed to write table row: no table printer") } for _, e := range fields { s, err := jsonScalarToString(e) if err != nil { return "", fmt.Errorf("failed to write table row: %v", err) } tp.AddField(s, tableprinter.WithTruncate(truncateMultiline)) } tp.EndRow() return "", nil } func tableRenderFunc(tp tableprinter.TablePrinter) (string, error) { if tp == nil { return "", fmt.Errorf("failed to render table: no table printer") } err := tp.Render() if err != nil { return "", fmt.Errorf("failed to render table: %v", err) } return "", nil } func jsonScalarToString(input interface{}) (string, error) { switch tt := input.(type) { case string: return tt, nil case float64: if math.Trunc(tt) == tt { return strconv.FormatFloat(tt, 'f', 0, 64), nil } else { return strconv.FormatFloat(tt, 'f', 2, 64), nil } case nil: return "", nil case bool: return fmt.Sprintf("%v", tt), nil default: return "", fmt.Errorf("cannot convert type to string: %v", tt) } } func timeAgo(ago time.Duration) string { if ago < time.Minute { return "just now" } if ago < time.Hour { return text.Pluralize(int(ago.Minutes()), "minute") + " ago" } if ago < 24*time.Hour { return text.Pluralize(int(ago.Hours()), "hour") + " ago" } if ago < 30*24*time.Hour { return text.Pluralize(int(ago.Hours())/24, "day") + " ago" } if ago < 365*24*time.Hour { return text.Pluralize(int(ago.Hours())/24/30, "month") + " ago" } return text.Pluralize(int(ago.Hours()/24/365), "year") + " ago" } // TruncateMultiline returns a copy of the string s that has been shortened to fit the maximum // display width. If string s has multiple lines the first line will be shortened and all others // removed. func truncateMultiline(maxWidth int, s string) string { if i := strings.IndexAny(s, "\r\n"); i >= 0 { s = s[:i] + ellipsis } return text.Truncate(maxWidth, s) } func hyperlinkFunc(link, text string) string { if text == "" { text = link } // See https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda return fmt.Sprintf("\x1b]8;;%s\x1b\\%s\x1b]8;;\x1b\\", link, text) } go-gh-1.2.1/pkg/template/template_test.go000066400000000000000000000270251437723465500203370ustar00rootroot00000000000000package template import ( "bytes" "fmt" "io" "log" "os" "strings" "testing" "time" "github.com/MakeNowJust/heredoc" "github.com/cli/go-gh/pkg/text" "github.com/stretchr/testify/assert" ) func ExampleTemplate() { // Information about the terminal can be obtained using the [pkg/term] package. colorEnabled := true termWidth := 14 json := strings.NewReader(heredoc.Doc(`[ {"number": 1, "title": "One"}, {"number": 2, "title": "Two"} ]`)) template := "HEADER\n\n{{range .}}{{tablerow .number .title}}{{end}}{{tablerender}}\nFOOTER" tmpl := New(os.Stdout, termWidth, colorEnabled) if err := tmpl.Parse(template); err != nil { log.Fatal(err) } if err := tmpl.Execute(json); err != nil { log.Fatal(err) } // Output: // HEADER // // 1 One // 2 Two // // FOOTER } func ExampleTemplate_Funcs() { // Information about the terminal can be obtained using the [pkg/term] package. colorEnabled := true termWidth := 14 json := strings.NewReader(heredoc.Doc(`[ {"num": 1, "thing": "apple"}, {"num": 2, "thing": "orange"} ]`)) template := "{{range .}}* {{pluralize .num .thing}}\n{{end}}" tmpl := New(os.Stdout, termWidth, colorEnabled) tmpl.Funcs(map[string]interface{}{ "pluralize": func(fields ...interface{}) (string, error) { if l := len(fields); l != 2 { return "", fmt.Errorf("wrong number of args for pluralize: want 2 got %d", l) } var ok bool var num float64 var thing string if num, ok = fields[0].(float64); !ok && num == float64(int(num)) { return "", fmt.Errorf("invalid value; expected int") } if thing, ok = fields[1].(string); !ok { return "", fmt.Errorf("invalid value; expected string") } return text.Pluralize(int(num), thing), nil }, }) if err := tmpl.Parse(template); err != nil { log.Fatal(err) } if err := tmpl.Execute(json); err != nil { log.Fatal(err) } // Output: // * 1 apple // * 2 oranges } func TestJsonScalarToString(t *testing.T) { tests := []struct { name string input interface{} want string wantErr bool }{ { name: "string", input: "hello", want: "hello", }, { name: "int", input: float64(1234), want: "1234", }, { name: "float", input: float64(12.34), want: "12.34", }, { name: "null", input: nil, want: "", }, { name: "true", input: true, want: "true", }, { name: "false", input: false, want: "false", }, { name: "object", input: map[string]interface{}{}, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := jsonScalarToString(tt.input) if tt.wantErr { assert.Error(t, err) return } assert.NoError(t, err) assert.Equal(t, tt.want, got) }) } } func TestExecute(t *testing.T) { type args struct { json io.Reader template string colorize bool } tests := []struct { name string args args wantW string wantErr bool }{ { name: "color", args: args{ json: strings.NewReader(`{}`), template: `{{color "blue+h" "songs are like tattoos"}}`, }, wantW: "\x1b[0;94msongs are like tattoos\x1b[0m", }, { name: "autocolor enabled", args: args{ json: strings.NewReader(`{}`), template: `{{autocolor "red" "stop"}}`, colorize: true, }, wantW: "\x1b[0;31mstop\x1b[0m", }, { name: "autocolor disabled", args: args{ json: strings.NewReader(`{}`), template: `{{autocolor "red" "go"}}`, }, wantW: "go", }, { name: "timefmt", args: args{ json: strings.NewReader(`{"created_at":"2008-02-25T20:18:33Z"}`), template: `{{.created_at | timefmt "Mon Jan 2, 2006"}}`, }, wantW: "Mon Feb 25, 2008", }, { name: "timeago", args: args{ json: strings.NewReader(fmt.Sprintf(`{"created_at":"%s"}`, time.Now().Add(-5*time.Minute).Format(time.RFC3339))), template: `{{.created_at | timeago}}`, }, wantW: "5 minutes ago", }, { name: "pluck", args: args{ json: strings.NewReader(heredoc.Doc(`[ {"name": "bug"}, {"name": "feature request"}, {"name": "chore"} ]`)), template: `{{range(pluck "name" .)}}{{. | printf "%s\n"}}{{end}}`, }, wantW: "bug\nfeature request\nchore\n", }, { name: "join", args: args{ json: strings.NewReader(`[ "bug", "feature request", "chore" ]`), template: `{{join "\t" .}}`, }, wantW: "bug\tfeature request\tchore", }, { name: "table", args: args{ json: strings.NewReader(heredoc.Doc(`[ {"number": 1, "title": "One"}, {"number": 20, "title": "Twenty"}, {"number": 3000, "title": "Three thousand"} ]`)), template: `{{range .}}{{tablerow (.number | printf "#%v") .title}}{{end}}`, }, wantW: heredoc.Doc(`#1 One #20 Twenty #3000 Three thousand `), }, { name: "table with multiline text", args: args{ json: strings.NewReader(heredoc.Doc(`[ {"number": 1, "title": "One\ranother line of text"}, {"number": 20, "title": "Twenty\nanother line of text"}, {"number": 3000, "title": "Three thousand\r\nanother line of text"} ]`)), template: `{{range .}}{{tablerow (.number | printf "#%v") .title}}{{end}}`, }, wantW: heredoc.Doc(`#1 One... #20 Twenty... #3000 Three thousand... `), }, { name: "table with mixed value types", args: args{ json: strings.NewReader(heredoc.Doc(`[ {"number": 1, "title": null, "float": false}, {"number": 20.1, "title": "Twenty-ish", "float": true}, {"number": 3000, "title": "Three thousand", "float": false} ]`)), template: `{{range .}}{{tablerow .number .title .float}}{{end}}`, }, wantW: heredoc.Doc(`1 false 20.10 Twenty-ish true 3000 Three thousand false `), }, { name: "table with color", args: args{ json: strings.NewReader(heredoc.Doc(`[ {"number": 1, "title": "One"} ]`)), template: `{{range .}}{{tablerow (.number | color "green") .title}}{{end}}`, }, wantW: "\x1b[0;32m1\x1b[0m One\n", }, { name: "table with header and footer", args: args{ json: strings.NewReader(heredoc.Doc(`[ {"number": 1, "title": "One"}, {"number": 2, "title": "Two"} ]`)), template: heredoc.Doc(`HEADER {{range .}}{{tablerow .number .title}}{{end}}FOOTER `), }, wantW: heredoc.Doc(`HEADER FOOTER 1 One 2 Two `), }, { name: "table with header and footer using endtable", args: args{ json: strings.NewReader(heredoc.Doc(`[ {"number": 1, "title": "One"}, {"number": 2, "title": "Two"} ]`)), template: heredoc.Doc(`HEADER {{range .}}{{tablerow .number .title}}{{end}}{{tablerender}}FOOTER `), }, wantW: heredoc.Doc(`HEADER 1 One 2 Two FOOTER `), }, { name: "multiple tables with different columns", args: args{ json: strings.NewReader(heredoc.Doc(`{ "issues": [ {"number": 1, "title": "One"}, {"number": 2, "title": "Two"} ], "prs": [ {"number": 3, "title": "Three", "reviewDecision": "REVIEW_REQUESTED"}, {"number": 4, "title": "Four", "reviewDecision": "CHANGES_REQUESTED"} ] }`)), template: heredoc.Doc(`{{tablerow "ISSUE" "TITLE"}}{{range .issues}}{{tablerow .number .title}}{{end}}{{tablerender}} {{tablerow "PR" "TITLE" "DECISION"}}{{range .prs}}{{tablerow .number .title .reviewDecision}}{{end}}`), }, wantW: heredoc.Docf(`ISSUE TITLE 1 One 2 Two PR TITLE DECISION 3 Three REVIEW_REQUESTED 4 Four CHANGES_REQUESTED `), }, { name: "truncate", args: args{ json: strings.NewReader(`{"title": "This is a long title"}`), template: `{{truncate 13 .title}}`, }, wantW: "This is a ...", }, { name: "truncate with JSON null", args: args{ json: strings.NewReader(`{}`), template: `{{ truncate 13 .title }}`, }, wantW: "", }, { name: "truncate with piped JSON null", args: args{ json: strings.NewReader(`{}`), template: `{{ .title | truncate 13 }}`, }, wantW: "", }, { name: "truncate with piped JSON null in parenthetical", args: args{ json: strings.NewReader(`{}`), template: `{{ (.title | truncate 13) }}`, }, wantW: "", }, { name: "truncate invalid type", args: args{ json: strings.NewReader(`{"title": 42}`), template: `{{ (.title | truncate 13) }}`, }, wantErr: true, }, { name: "hyperlink enabled", args: args{ json: strings.NewReader(`{"link":"https://github.com"}`), template: `{{ hyperlink .link "" }}`, }, wantW: "\x1b]8;;https://github.com\x1b\\https://github.com\x1b]8;;\x1b\\", }, { name: "hyperlink with text enabled", args: args{ json: strings.NewReader(`{"link":"https://github.com","text":"GitHub"}`), template: `{{ hyperlink .link .text }}`, }, wantW: "\x1b]8;;https://github.com\x1b\\GitHub\x1b]8;;\x1b\\", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := &bytes.Buffer{} tmpl := New(w, 80, tt.args.colorize) err := tmpl.Parse(tt.args.template) assert.NoError(t, err) err = tmpl.Execute(tt.args.json) if tt.wantErr { assert.Error(t, err) return } assert.NoError(t, err) err = tmpl.Flush() assert.NoError(t, err) assert.Equal(t, tt.wantW, w.String()) }) } } func TestTruncateMultiline(t *testing.T) { type args struct { max int s string } tests := []struct { name string args args want string }{ { name: "exactly minimum width", args: args{ max: 5, s: "short", }, want: "short", }, { name: "exactly minimum width with new line", args: args{ max: 5, s: "short\n", }, want: "sh...", }, { name: "less than minimum width", args: args{ max: 4, s: "short", }, want: "shor", }, { name: "less than minimum width with new line", args: args{ max: 4, s: "short\n", }, want: "shor", }, { name: "first line of multiple is short enough", args: args{ max: 80, s: "short\n\nthis is a new line", }, want: "short...", }, { name: "using Windows line endings", args: args{ max: 80, s: "short\r\n\r\nthis is a new line", }, want: "short...", }, { name: "using older MacOS line endings", args: args{ max: 80, s: "short\r\rthis is a new line", }, want: "short...", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := truncateMultiline(tt.args.max, tt.args.s) assert.Equal(t, tt.want, got) }) } } func TestFuncs(t *testing.T) { w := &bytes.Buffer{} tmpl := New(w, 80, false) // Override "truncate" and define a new "foo" function. tmpl.Funcs(map[string]interface{}{ "truncate": func(fields ...interface{}) (string, error) { if l := len(fields); l != 2 { return "", fmt.Errorf("wrong number of args for truncate: want 2 got %d", l) } var ok bool var width int var input string if width, ok = fields[0].(int); !ok { return "", fmt.Errorf("invalid value; expected int") } if input, ok = fields[1].(string); !ok { return "", fmt.Errorf("invalid value; expected string") } return input[:width], nil }, "foo": func(fields ...interface{}) (string, error) { return "test", nil }, }) err := tmpl.Parse(`{{ .text | truncate 5 }} {{ .status | color "green" }} {{ foo }}`) assert.NoError(t, err) r := strings.NewReader(`{"text":"truncated","status":"open"}`) err = tmpl.Execute(r) assert.NoError(t, err) err = tmpl.Flush() assert.NoError(t, err) assert.Equal(t, "trunc \x1b[0;32mopen\x1b[0m test", w.String()) } go-gh-1.2.1/pkg/term/000077500000000000000000000000001437723465500142645ustar00rootroot00000000000000go-gh-1.2.1/pkg/term/console.go000066400000000000000000000003651437723465500162610ustar00rootroot00000000000000//go:build !windows // +build !windows package term import ( "errors" "os" ) func enableVirtualTerminalProcessing(f *os.File) error { return errors.New("not implemented") } func openTTY() (*os.File, error) { return os.Open("/dev/tty") } go-gh-1.2.1/pkg/term/console_windows.go000066400000000000000000000006521437723465500200320ustar00rootroot00000000000000//go:build windows // +build windows package term import ( "os" "golang.org/x/sys/windows" ) func enableVirtualTerminalProcessing(f *os.File) error { stdout := windows.Handle(f.Fd()) var originalMode uint32 windows.GetConsoleMode(stdout, &originalMode) return windows.SetConsoleMode(stdout, originalMode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING) } func openTTY() (*os.File, error) { return os.Open("CONOUT$") } go-gh-1.2.1/pkg/term/env.go000066400000000000000000000111431437723465500154030ustar00rootroot00000000000000// Package term provides information about the terminal that the current process is connected to (if any), // for example measuring the dimensions of the terminal and inspecting whether it's safe to output color. package term import ( "io" "os" "strconv" "strings" "github.com/muesli/termenv" "golang.org/x/term" ) // Term represents information about the terminal that a process is connected to. type Term struct { in *os.File out *os.File errOut *os.File isTTY bool colorEnabled bool is256enabled bool hasTrueColor bool width int widthPercent int } // FromEnv initializes a Term from [os.Stdout] and environment variables: // - GH_FORCE_TTY // - NO_COLOR // - CLICOLOR // - CLICOLOR_FORCE // - TERM // - COLORTERM func FromEnv() Term { var stdoutIsTTY bool var isColorEnabled bool var termWidthOverride int var termWidthPercentage int spec := os.Getenv("GH_FORCE_TTY") if spec != "" { stdoutIsTTY = true isColorEnabled = !IsColorDisabled() if w, err := strconv.Atoi(spec); err == nil { termWidthOverride = w } else if strings.HasSuffix(spec, "%") { if p, err := strconv.Atoi(spec[:len(spec)-1]); err == nil { termWidthPercentage = p } } } else { stdoutIsTTY = IsTerminal(os.Stdout) isColorEnabled = envColorForced() || (!IsColorDisabled() && stdoutIsTTY) } isVirtualTerminal := false if stdoutIsTTY { if err := enableVirtualTerminalProcessing(os.Stdout); err == nil { isVirtualTerminal = true } } return Term{ in: os.Stdin, out: os.Stdout, errOut: os.Stderr, isTTY: stdoutIsTTY, colorEnabled: isColorEnabled, is256enabled: isVirtualTerminal || is256ColorSupported(), hasTrueColor: isVirtualTerminal || isTrueColorSupported(), width: termWidthOverride, widthPercent: termWidthPercentage, } } // In is the reader reading from standard input. func (t Term) In() io.Reader { return t.in } // Out is the writer writing to standard output. func (t Term) Out() io.Writer { return t.out } // ErrOut is the writer writing to standard error. func (t Term) ErrOut() io.Writer { return t.errOut } // IsTerminalOutput returns true if standard output is connected to a terminal. func (t Term) IsTerminalOutput() bool { return t.isTTY } // IsColorEnabled reports whether it's safe to output ANSI color sequences, depending on IsTerminalOutput // and environment variables. func (t Term) IsColorEnabled() bool { return t.colorEnabled } // Is256ColorSupported reports whether the terminal advertises ANSI 256 color codes. func (t Term) Is256ColorSupported() bool { return t.is256enabled } // IsTrueColorSupported reports whether the terminal advertises support for ANSI true color sequences. func (t Term) IsTrueColorSupported() bool { return t.hasTrueColor } // Size returns the width and height of the terminal that the current process is attached to. // In case of errors, the numeric values returned are -1. func (t Term) Size() (int, int, error) { if t.width > 0 { return t.width, -1, nil } ttyOut := t.out if ttyOut == nil || !IsTerminal(ttyOut) { if f, err := openTTY(); err == nil { defer f.Close() ttyOut = f } else { return -1, -1, err } } width, height, err := terminalSize(ttyOut) if err == nil && t.widthPercent > 0 { return int(float64(width) * float64(t.widthPercent) / 100), height, nil } return width, height, err } // Theme returns the theme of the terminal by analyzing the background color of the terminal. func (t Term) Theme() string { if !t.IsColorEnabled() { return "none" } if termenv.HasDarkBackground() { return "dark" } return "light" } // IsTerminal reports whether a file descriptor is connected to a terminal. func IsTerminal(f *os.File) bool { return term.IsTerminal(int(f.Fd())) } func terminalSize(f *os.File) (int, int, error) { return term.GetSize(int(f.Fd())) } // IsColorDisabled returns true if environment variables NO_COLOR or CLICOLOR prohibit usage of color codes // in terminal output. func IsColorDisabled() bool { return os.Getenv("NO_COLOR") != "" || os.Getenv("CLICOLOR") == "0" } func envColorForced() bool { return os.Getenv("CLICOLOR_FORCE") != "" && os.Getenv("CLICOLOR_FORCE") != "0" } func is256ColorSupported() bool { return isTrueColorSupported() || strings.Contains(os.Getenv("TERM"), "256") || strings.Contains(os.Getenv("COLORTERM"), "256") } func isTrueColorSupported() bool { term := os.Getenv("TERM") colorterm := os.Getenv("COLORTERM") return strings.Contains(term, "24bit") || strings.Contains(term, "truecolor") || strings.Contains(colorterm, "24bit") || strings.Contains(colorterm, "truecolor") } go-gh-1.2.1/pkg/term/env_test.go000066400000000000000000000054041437723465500164450ustar00rootroot00000000000000// Package term provides information about the terminal that the current process is connected to (if any), // for example measuring the dimensions of the terminal and inspecting whether it's safe to output color. package term import ( "testing" ) func TestFromEnv(t *testing.T) { tests := []struct { name string env map[string]string wantTerminal bool wantColor bool want256Color bool wantTrueColor bool }{ { name: "default", env: map[string]string{ "GH_FORCE_TTY": "", "CLICOLOR": "", "CLICOLOR_FORCE": "", "NO_COLOR": "", "TERM": "", "COLORTERM": "", }, wantTerminal: false, wantColor: false, want256Color: false, wantTrueColor: false, }, { name: "force color", env: map[string]string{ "GH_FORCE_TTY": "", "CLICOLOR": "", "CLICOLOR_FORCE": "1", "NO_COLOR": "", "TERM": "", "COLORTERM": "", }, wantTerminal: false, wantColor: true, want256Color: false, wantTrueColor: false, }, { name: "force tty", env: map[string]string{ "GH_FORCE_TTY": "true", "CLICOLOR": "", "CLICOLOR_FORCE": "", "NO_COLOR": "", "TERM": "", "COLORTERM": "", }, wantTerminal: true, wantColor: true, want256Color: false, wantTrueColor: false, }, { name: "has 256-color support", env: map[string]string{ "GH_FORCE_TTY": "true", "CLICOLOR": "", "CLICOLOR_FORCE": "", "NO_COLOR": "", "TERM": "256-color", "COLORTERM": "", }, wantTerminal: true, wantColor: true, want256Color: true, wantTrueColor: false, }, { name: "has truecolor support", env: map[string]string{ "GH_FORCE_TTY": "true", "CLICOLOR": "", "CLICOLOR_FORCE": "", "NO_COLOR": "", "TERM": "truecolor", "COLORTERM": "", }, wantTerminal: true, wantColor: true, want256Color: true, wantTrueColor: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { for key, value := range tt.env { t.Setenv(key, value) } terminal := FromEnv() if got := terminal.IsTerminalOutput(); got != tt.wantTerminal { t.Errorf("expected terminal %v, got %v", tt.wantTerminal, got) } if got := terminal.IsColorEnabled(); got != tt.wantColor { t.Errorf("expected color %v, got %v", tt.wantColor, got) } if got := terminal.Is256ColorSupported(); got != tt.want256Color { t.Errorf("expected 256-color %v, got %v", tt.want256Color, got) } if got := terminal.IsTrueColorSupported(); got != tt.wantTrueColor { t.Errorf("expected truecolor %v, got %v", tt.wantTrueColor, got) } }) } } go-gh-1.2.1/pkg/text/000077500000000000000000000000001437723465500143015ustar00rootroot00000000000000go-gh-1.2.1/pkg/text/text.go000066400000000000000000000041471437723465500156220ustar00rootroot00000000000000// Package text is a set of utility functions for text processing and outputting to the terminal. package text import ( "fmt" "regexp" "strings" "time" "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/truncate" ) const ( ellipsis = "..." minWidthForEllipsis = len(ellipsis) + 2 ) var indentRE = regexp.MustCompile(`(?m)^`) // Indent returns a copy of the string s with indent prefixed to it, will apply indent // to each line of the string. func Indent(s, indent string) string { if len(strings.TrimSpace(s)) == 0 { return s } return indentRE.ReplaceAllLiteralString(s, indent) } // DisplayWidth calculates what the rendered width of string s will be. func DisplayWidth(s string) int { return ansi.PrintableRuneWidth(s) } // Truncate returns a copy of the string s that has been shortened to fit the maximum display width. func Truncate(maxWidth int, s string) string { w := DisplayWidth(s) if w <= maxWidth { return s } tail := "" if maxWidth >= minWidthForEllipsis { tail = ellipsis } r := truncate.StringWithTail(s, uint(maxWidth), tail) if DisplayWidth(r) < maxWidth { r += " " } return r } // Pluralize returns a concatenated string with num and the plural form of thing if necessary. func Pluralize(num int, thing string) string { if num == 1 { return fmt.Sprintf("%d %s", num, thing) } return fmt.Sprintf("%d %ss", num, thing) } func fmtDuration(amount int, unit string) string { return fmt.Sprintf("about %s ago", Pluralize(amount, unit)) } // RelativeTimeAgo returns a human readable string of the time duration between a and b that is estimated // to the nearest unit of time. func RelativeTimeAgo(a, b time.Time) string { ago := a.Sub(b) if ago < time.Minute { return "less than a minute ago" } if ago < time.Hour { return fmtDuration(int(ago.Minutes()), "minute") } if ago < 24*time.Hour { return fmtDuration(int(ago.Hours()), "hour") } if ago < 30*24*time.Hour { return fmtDuration(int(ago.Hours())/24, "day") } if ago < 365*24*time.Hour { return fmtDuration(int(ago.Hours())/24/30, "month") } return fmtDuration(int(ago.Hours()/24/365), "year") } go-gh-1.2.1/pkg/text/text_test.go000066400000000000000000000124321437723465500166550ustar00rootroot00000000000000package text import ( "testing" "time" "github.com/stretchr/testify/assert" ) func TestRelativeTimeAgo(t *testing.T) { const form = "2006-Jan-02 15:04:05" now, _ := time.Parse(form, "2020-Nov-22 14:00:00") cases := map[string]string{ "2020-Nov-22 14:00:00": "less than a minute ago", "2020-Nov-22 13:59:30": "less than a minute ago", "2020-Nov-22 13:59:00": "about 1 minute ago", "2020-Nov-22 13:30:00": "about 30 minutes ago", "2020-Nov-22 13:00:00": "about 1 hour ago", "2020-Nov-22 02:00:00": "about 12 hours ago", "2020-Nov-21 14:00:00": "about 1 day ago", "2020-Nov-07 14:00:00": "about 15 days ago", "2020-Oct-24 14:00:00": "about 29 days ago", "2020-Oct-23 14:00:00": "about 1 month ago", "2020-Sep-23 14:00:00": "about 2 months ago", "2019-Nov-22 14:00:00": "about 1 year ago", "2018-Nov-22 14:00:00": "about 2 years ago", } for createdAt, expected := range cases { d, err := time.Parse(form, createdAt) assert.NoError(t, err) relative := RelativeTimeAgo(now, d) assert.Equal(t, expected, relative) } } func TestTruncate(t *testing.T) { type args struct { max int s string } tests := []struct { name string args args want string }{ { name: "empty", args: args{ s: "", max: 10, }, want: "", }, { name: "short", args: args{ s: "hello", max: 3, }, want: "hel", }, { name: "long", args: args{ s: "hello world", max: 5, }, want: "he...", }, { name: "no truncate", args: args{ s: "hello world", max: 11, }, want: "hello world", }, { name: "Short enough", args: args{ max: 5, s: "short", }, want: "short", }, { name: "Too short", args: args{ max: 4, s: "short", }, want: "shor", }, { name: "Japanese", args: args{ max: 11, s: "テストテストテストテスト", }, want: "テストテ...", }, { name: "Japanese filled", args: args{ max: 11, s: "aテストテストテストテスト", }, want: "aテスト... ", }, { name: "Chinese", args: args{ max: 11, s: "幫新舉報違章工廠新增編號", }, want: "幫新舉報...", }, { name: "Chinese filled", args: args{ max: 11, s: "a幫新舉報違章工廠新增編號", }, want: "a幫新舉... ", }, { name: "Korean", args: args{ max: 11, s: "프로젝트 내의", }, want: "프로젝트...", }, { name: "Korean filled", args: args{ max: 11, s: "a프로젝트 내의", }, want: "a프로젝... ", }, { name: "Emoji", args: args{ max: 11, s: "💡💡💡💡💡💡💡💡💡💡💡💡", }, want: "💡💡💡💡...", }, { name: "Accented characters", args: args{ max: 11, s: "é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́", }, want: "é́́é́́é́́é́́é́́é́́é́́é́́...", }, { name: "Red accented characters", args: args{ max: 11, s: "\x1b[0;31mé́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́\x1b[0m", }, want: "\x1b[0;31mé́́é́́é́́é́́é́́é́́é́́é́́...\x1b[0m", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := Truncate(tt.args.max, tt.args.s) assert.Equal(t, tt.want, got) }) } } func TestDisplayWidth(t *testing.T) { tests := []struct { name string text string want int }{ { name: "check mark", text: `✓`, want: 1, }, { name: "bullet icon", text: `•`, want: 1, }, { name: "middle dot", text: `·`, want: 1, }, { name: "ellipsis", text: `…`, want: 1, }, { name: "right arrow", text: `→`, want: 1, }, { name: "smart double quotes", text: `“”`, want: 2, }, { name: "smart single quotes", text: `‘’`, want: 2, }, { name: "em dash", text: `—`, want: 1, }, { name: "en dash", text: `–`, want: 1, }, { name: "emoji", text: `👍`, want: 2, }, { name: "accent character", text: `é́́`, want: 1, }, { name: "color codes", text: "\x1b[0;31mred\x1b[0m", want: 3, }, { name: "empty", text: "", want: 0, }, { name: "Latin", text: "hello world 123$#!", want: 18, }, { name: "Asian", text: "つのだ☆HIRO", want: 11, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := DisplayWidth(tt.text) assert.Equal(t, tt.want, got) }) } } func TestIndent(t *testing.T) { type args struct { s string indent string } tests := []struct { name string args args want string }{ { name: "empty", args: args{ s: "", indent: "--", }, want: "", }, { name: "blank", args: args{ s: "\n", indent: "--", }, want: "\n", }, { name: "indent", args: args{ s: "one\ntwo\nthree", indent: "--", }, want: "--one\n--two\n--three", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := Indent(tt.args.s, tt.args.indent) assert.Equal(t, tt.want, got) }) } }