pax_global_header00006660000000000000000000000064145713362610014522gustar00rootroot0000000000000052 comment=45fa8a46ae55cf5975cc2bd891b46ffc3d50e4f6 go-gh-2.6.0/000077500000000000000000000000001457133626100125305ustar00rootroot00000000000000go-gh-2.6.0/.github/000077500000000000000000000000001457133626100140705ustar00rootroot00000000000000go-gh-2.6.0/.github/CODE-OF-CONDUCT.md000066400000000000000000000125571457133626100165350ustar00rootroot00000000000000# 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-2.6.0/.github/CONTRIBUTING.md000066400000000000000000000045051457133626100163250ustar00rootroot00000000000000## 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-2.6.0/.github/workflows/000077500000000000000000000000001457133626100161255ustar00rootroot00000000000000go-gh-2.6.0/.github/workflows/codeql-analysis.yml000066400000000000000000000010611457133626100217360ustar00rootroot00000000000000name: 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@v4 - 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-2.6.0/.github/workflows/lint.yml000066400000000000000000000007741457133626100176260ustar00rootroot00000000000000name: 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.21" - name: Checkout repository uses: actions/checkout@v4 - name: Check dependencies run: | go mod tidy git diff --exit-code go.mod - name: Lint uses: golangci/golangci-lint-action@v3.4.0 with: version: v1.54 go-gh-2.6.0/.github/workflows/test.yml000066400000000000000000000007501457133626100176310ustar00rootroot00000000000000name: Test on: [push, pull_request] permissions: contents: read jobs: test: strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] go: ["1.21"] 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@v4 - name: Run tests run: go test -v ./... go-gh-2.6.0/.golangci.yml000066400000000000000000000003541457133626100151160ustar00rootroot00000000000000linters: 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-2.6.0/LICENSE000066400000000000000000000020541457133626100135360ustar00rootroot00000000000000MIT 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-2.6.0/README.md000066400000000000000000000045631457133626100140170ustar00rootroot00000000000000# 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: - [`repository.Current()`](https://pkg.go.dev/github.com/cli/go-gh/v2/pkg/repository#current) respects the value of the `GH_REPO` environment variable and reads from git remote configuration as fallback. - GitHub API requests will be authenticated using the same mechanism as `gh`, i.e. using the values of `GH_TOKEN` and `GH_HOST` environment variables and falling back to the user's stored OAuth token. - [Terminal capabilities](https://pkg.go.dev/github.com/cli/go-gh/v2/pkg/term) are determined by taking environment variables `GH_FORCE_TTY`, `NO_COLOR`, `CLICOLOR`, etc. into account. - Generating [table](https://pkg.go.dev/github.com/cli/go-gh/v2/pkg/tableprinter) or [Go template](https://pkg.go.dev/github.com/cli/go-gh/pkg/template) output uses the same engine as gh. - The [`browser`](https://pkg.go.dev/github.com/cli/go-gh/v2/pkg/browser) module activates the user's preferred web browser. ## Usage See the full `go-gh` [reference documentation](https://pkg.go.dev/github.com/cli/go-gh/v2) for more information ```golang package main import ( "fmt" "log" "github.com/cli/go-gh/v2" "github.com/cli/go-gh/v2/pkg/api" ) 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 client to retrieve repository tags. client, err := api.DefaultRESTClient() 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-2.6.0/example_gh_test.go000066400000000000000000000166711457133626100162420ustar00rootroot00000000000000package gh_test import ( "encoding/json" "fmt" "io" "log" "net/http" "os" "regexp" "time" gh "github.com/cli/go-gh/v2" "github.com/cli/go-gh/v2/pkg/api" "github.com/cli/go-gh/v2/pkg/repository" "github.com/cli/go-gh/v2/pkg/tableprinter" "github.com/cli/go-gh/v2/pkg/term" graphql "github.com/cli/shurcooL-graphql" ) // 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 ExampleDefaultRESTClient() { client, err := api.DefaultRESTClient() 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() { 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 := api.NewRESTClient(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 := api.NewRESTClient(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 := api.DefaultRESTClient() 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 GraphQL API. func ExampleDefaultGraphQLClient() { client, err := api.DefaultGraphQLClient() 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 GraphQL API. // Enable caching and request timeout. func ExampleGraphQLClient() { opts := api.ClientOptions{ EnableCache: true, Timeout: 5 * time.Second, } client, err := api.NewGraphQLClient(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 GraphQL API. func ExampleGraphQLClient_mutate() { client, err := api.DefaultGraphQLClient() 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 GraphQL API with paginated results. func ExampleGraphQLClient_pagination() { client, err := api.DefaultGraphQLClient() 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 ExampleCurrent() { repo, err := repository.Current() 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-2.6.0/gh.go000066400000000000000000000037531457133626100134650ustar00rootroot00000000000000// 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" "context" "fmt" "io" "os" "os/exec" "github.com/cli/safeexec" ) // Exec invokes a gh command in a subprocess and captures the output and error streams. func Exec(args ...string) (stdout, stderr bytes.Buffer, err error) { ghExe, err := Path() if err != nil { return } err = run(context.Background(), ghExe, nil, nil, &stdout, &stderr, args) return } // ExecContext invokes a gh command in a subprocess and captures the output and error streams. func ExecContext(ctx context.Context, args ...string) (stdout, stderr bytes.Buffer, err error) { ghExe, err := Path() if err != nil { return } err = run(ctx, ghExe, nil, nil, &stdout, &stderr, args) return } // Exec invokes a gh command in a subprocess with its stdin, stdout, and stderr streams connected to // those of the parent process. This is suitable for running gh commands with interactive prompts. func ExecInteractive(ctx context.Context, args ...string) error { ghExe, err := Path() if err != nil { return err } return run(ctx, ghExe, nil, os.Stdin, os.Stdout, os.Stderr, args) } // Path searches for an executable named "gh" in the directories named by the PATH environment variable. // If the executable is found the result is an absolute path. func Path() (string, error) { if ghExe := os.Getenv("GH_PATH"); ghExe != "" { return ghExe, nil } return safeexec.LookPath("gh") } func run(ctx context.Context, ghExe string, env []string, stdin io.Reader, stdout, stderr io.Writer, args []string) error { cmd := exec.CommandContext(ctx, ghExe, args...) cmd.Stdin = stdin cmd.Stdout = stdout cmd.Stderr = stderr if env != nil { cmd.Env = env } if err := cmd.Run(); err != nil { return fmt.Errorf("gh execution failed: %w", err) } return nil } go-gh-2.6.0/gh_test.go000066400000000000000000000036771457133626100145310ustar00rootroot00000000000000package gh import ( "bytes" "context" "fmt" "os" "testing" "time" "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 TestHelperProcessLongRunning(t *testing.T) { if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" { return } args := os.Args[3:] fmt.Fprintf(os.Stdout, "%v", args) fmt.Fprint(os.Stderr, "going to sleep...") time.Sleep(10 * time.Second) fmt.Fprint(os.Stderr, "...going to exit") os.Exit(0) } func TestRun(t *testing.T) { var stdout, stderr bytes.Buffer err := run(context.TODO(), os.Args[0], []string{"GH_WANT_HELPER_PROCESS=1"}, nil, &stdout, &stderr, []string{"-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) { var stdout, stderr bytes.Buffer err := run(context.TODO(), os.Args[0], []string{"GH_WANT_HELPER_PROCESS=1"}, nil, &stdout, &stderr, []string{"-test.run=TestHelperProcess", "--", "gh", "error"}) assert.EqualError(t, err, "gh execution failed: exit status 1") assert.Equal(t, "", stdout.String()) assert.Equal(t, "process exited with error", stderr.String()) } func TestRunInteractiveContextCanceled(t *testing.T) { // pass current time to ensure that deadline has already passed ctx, cancel := context.WithDeadline(context.Background(), time.Now()) cancel() err := run(ctx, os.Args[0], []string{"GH_WANT_HELPER_PROCESS=1"}, nil, nil, nil, []string{"-test.run=TestHelperProcessLongRunning", "--", "gh", "issue", "list"}) assert.EqualError(t, err, "gh execution failed: context deadline exceeded") } go-gh-2.6.0/go.mod000066400000000000000000000036111457133626100136370ustar00rootroot00000000000000module github.com/cli/go-gh/v2 go 1.21 require ( github.com/AlecAivazis/survey/v2 v2.3.7 github.com/MakeNowJust/heredoc v1.0.0 github.com/charmbracelet/glamour v0.6.0 github.com/cli/browser v1.3.0 github.com/cli/safeexec v1.0.0 github.com/cli/shurcooL-graphql v0.0.4 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/henvic/httpretty v0.0.6 github.com/itchyny/gojq v0.12.13 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.13.0 github.com/stretchr/testify v1.7.0 github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e golang.org/x/sys v0.13.0 golang.org/x/term v0.13.0 golang.org/x/text v0.13.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/aymanbagabas/go-osc52 v1.0.3 // 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.5 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // 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.19 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/microcosm-cc/bluemonday v1.0.26 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.4 // indirect github.com/yuin/goldmark v1.5.2 // indirect github.com/yuin/goldmark-emoji v1.0.1 // indirect golang.org/x/net v0.17.0 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect ) go-gh-2.6.0/go.sum000066400000000000000000000310741457133626100136700ustar00rootroot00000000000000github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= 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/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg= github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 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.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc= github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc= github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= 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.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY= github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk= github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 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/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/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog= github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68= github.com/itchyny/gojq v0.12.13 h1:IxyYlHYIlspQHHTE0f3cJF0NKDMfajxViuhBLnHd/QU= github.com/itchyny/gojq v0.12.13/go.mod h1:JzwzAqenfhrPUuwbmEz3nu3JQmFLlQTQMUcOdnu/Sf4= github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE= github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= 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.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 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.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= 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.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM= github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58= github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs= 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.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0= github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc= 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/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU= github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 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/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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-20210831042530-f4d43177bf5e/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-20220722155257-8c9f86f7a55f/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.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.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/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-2.6.0/internal/000077500000000000000000000000001457133626100143445ustar00rootroot00000000000000go-gh-2.6.0/internal/git/000077500000000000000000000000001457133626100151275ustar00rootroot00000000000000go-gh-2.6.0/internal/git/git.go000066400000000000000000000013201457133626100162350ustar00rootroot00000000000000package 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-2.6.0/internal/git/git_test.go000066400000000000000000000021341457133626100173000ustar00rootroot00000000000000package 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-2.6.0/internal/git/remote.go000066400000000000000000000056351457133626100167620ustar00rootroot00000000000000package 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-2.6.0/internal/git/remote_test.go000066400000000000000000000061651457133626100200200ustar00rootroot00000000000000package 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-2.6.0/internal/git/url.go000066400000000000000000000035411457133626100162630ustar00rootroot00000000000000package 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-2.6.0/internal/git/url_test.go000066400000000000000000000144501457133626100173230ustar00rootroot00000000000000package 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-2.6.0/internal/set/000077500000000000000000000000001457133626100151375ustar00rootroot00000000000000go-gh-2.6.0/internal/set/string_set.go000066400000000000000000000020461457133626100176510ustar00rootroot00000000000000package 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-2.6.0/internal/set/string_set_test.go000066400000000000000000000010041457133626100207010ustar00rootroot00000000000000package 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-2.6.0/internal/yamlmap/000077500000000000000000000000001457133626100160045ustar00rootroot00000000000000go-gh-2.6.0/internal/yamlmap/yaml_map.go000066400000000000000000000113531457133626100201350ustar00rootroot00000000000000// 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 NullValue() *Map { return &Map{&yaml.Node{ Kind: yaml.ScalarNode, Tag: "!!null", }} } 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-2.6.0/internal/yamlmap/yaml_map_test.go000066400000000000000000000122641457133626100211760ustar00rootroot00000000000000package 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-2.6.0/pkg/000077500000000000000000000000001457133626100133115ustar00rootroot00000000000000go-gh-2.6.0/pkg/api/000077500000000000000000000000001457133626100140625ustar00rootroot00000000000000go-gh-2.6.0/pkg/api/cache.go000066400000000000000000000100631457133626100154540ustar00rootroot00000000000000package 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) (storeErr error) { cacheFile := fs.filePath(key) fs.mu.Lock() defer fs.mu.Unlock() if storeErr = os.MkdirAll(filepath.Dir(cacheFile), 0755); storeErr != nil { return } var f *os.File if f, storeErr = os.OpenFile(cacheFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600); storeErr != nil { return } defer func() { if err := f.Close(); storeErr == nil && err != nil { storeErr = err } }() var origBody io.ReadCloser if res.Body != nil { origBody, res.Body = copyStream(res.Body) defer res.Body.Close() } storeErr = res.Write(f) if origBody != nil { res.Body = origBody } return } 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-2.6.0/pkg/api/cache_test.go000066400000000000000000000125631457133626100165220ustar00rootroot00000000000000package api import ( "bytes" "fmt" "io" "net/http" "path/filepath" "testing" "time" "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, err := NewHTTPClient( ClientOptions{ Host: "github.com", AuthToken: "token", Transport: fakeHTTP, EnableCache: true, CacheDir: cacheDir, LogIgnoreEnv: true, }, ) assert.NoError(t, err) do := func(method, url string, body io.Reader) (string, error) { req, err := http.NewRequest(method, url, body) 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 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, err := NewHTTPClient( ClientOptions{ Host: "github.com", AuthToken: "token", Transport: fakeHTTP, EnableCache: false, CacheDir: cacheDir, LogIgnoreEnv: true, }, ) assert.NoError(t, err) do := func(method, url string, body io.Reader) (string, error) { req, err := http.NewRequest(method, url, body) 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 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-2.6.0/pkg/api/client_options.go000066400000000000000000000064511457133626100174500ustar00rootroot00000000000000// Package api is a set of types for interacting with the GitHub API. package api import ( "fmt" "io" "net/http" "time" "github.com/cli/go-gh/v2/pkg/auth" "github.com/cli/go-gh/v2/pkg/config" ) // ClientOptions holds available options to configure API clients. type ClientOptions struct { // AuthToken is the authorization token that will be used // to authenticate against API endpoints. AuthToken string // CacheDir is the directory to use for cached API requests. // Default is the same directory that gh uses for caching. CacheDir string // CacheTTL is the time that cached API requests are valid for. // Default is 24 hours. CacheTTL time.Duration // EnableCache specifies if API requests will be cached or not. // Default is no caching. EnableCache bool // Headers are the headers that will be sent with every API request. // Default headers set are Accept, Content-Type, Time-Zone, and User-Agent. // Default headers will be overridden by keys specified in Headers. Headers map[string]string // Host is the default host that API requests will be sent to. Host string // Log specifies a writer to write API request logs to. Default is to respect the GH_DEBUG environment // variable, and no logging otherwise. Log io.Writer // LogIgnoreEnv disables respecting the GH_DEBUG environment variable. This can be useful in test mode // or when the extension already offers its own controls for logging to the user. LogIgnoreEnv bool // LogColorize enables colorized logging to Log for display in a terminal. // Default is no coloring. LogColorize bool // LogVerboseHTTP enables logging HTTP headers and bodies to Log. // Default is only logging request URLs and response statuses. LogVerboseHTTP bool // SkipDefaultHeaders disables setting of the default headers. SkipDefaultHeaders bool // Timeout specifies a time limit for each API request. // Default is no timeout. Timeout time.Duration // Transport specifies the mechanism by which individual API requests are made. // If both Transport and UnixDomainSocket are specified then Transport takes // precedence. Due to this behavior any value set for Transport needs to manually // handle routing to UnixDomainSocket if necessary. Generally, setting Transport // should be reserved for testing purposes. // Default is http.DefaultTransport. Transport http.RoundTripper // UnixDomainSocket specifies the Unix domain socket address by which individual // API requests will be routed. If specifed, this will form the base of the API // request transport chain. // Default is no socket address. UnixDomainSocket string } func optionsNeedResolution(opts ClientOptions) bool { if opts.Host == "" { return true } if opts.AuthToken == "" { return true } if opts.UnixDomainSocket == "" && opts.Transport == nil { return true } return false } func resolveOptions(opts ClientOptions) (ClientOptions, error) { cfg, _ := config.Read(nil) if opts.Host == "" { opts.Host, _ = auth.DefaultHost() } if opts.AuthToken == "" { opts.AuthToken, _ = auth.TokenForHost(opts.Host) if opts.AuthToken == "" { return ClientOptions{}, fmt.Errorf("authentication token not found for host %s", opts.Host) } } if opts.UnixDomainSocket == "" && cfg != nil { opts.UnixDomainSocket, _ = cfg.Get([]string{"http_unix_socket"}) } return opts, nil } go-gh-2.6.0/pkg/api/client_options_test.go000066400000000000000000000064361457133626100205120ustar00rootroot00000000000000package api import ( "net/http" "testing" "github.com/stretchr/testify/assert" ) func TestResolveOptions(t *testing.T) { stubConfig(t, testConfigWithSocket()) tests := []struct { name string opts ClientOptions wantAuthToken string wantHost string wantSocket string }{ { name: "honors consumer provided ClientOptions", opts: ClientOptions{ Host: "test.com", AuthToken: "token_from_opts", UnixDomainSocket: "socket_from_opts", }, wantAuthToken: "token_from_opts", wantHost: "test.com", wantSocket: "socket_from_opts", }, { name: "uses config values if there are no consumer provided ClientOptions", opts: ClientOptions{}, wantAuthToken: "token", wantHost: "github.com", wantSocket: "socket", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { opts, err := resolveOptions(tt.opts) assert.NoError(t, err) assert.Equal(t, tt.wantHost, opts.Host) assert.Equal(t, tt.wantAuthToken, opts.AuthToken) assert.Equal(t, tt.wantSocket, opts.UnixDomainSocket) }) } } func TestOptionsNeedResolution(t *testing.T) { tests := []struct { name string opts ClientOptions out bool }{ { name: "Host, AuthToken, and UnixDomainSocket specified", opts: ClientOptions{ Host: "test.com", AuthToken: "token", UnixDomainSocket: "socket", }, out: false, }, { name: "Host, AuthToken, and Transport specified", opts: ClientOptions{ Host: "test.com", AuthToken: "token", Transport: http.DefaultTransport, }, out: false, }, { name: "Host, and AuthToken specified", opts: ClientOptions{ Host: "test.com", AuthToken: "token", }, out: true, }, { name: "Host, and UnixDomainSocket specified", opts: ClientOptions{ Host: "test.com", UnixDomainSocket: "socket", }, out: true, }, { name: "Host, and Transport specified", opts: ClientOptions{ Host: "test.com", Transport: http.DefaultTransport, }, out: true, }, { name: "AuthToken, and UnixDomainSocket specified", opts: ClientOptions{ AuthToken: "token", UnixDomainSocket: "socket", }, out: true, }, { name: "AuthToken, and Transport specified", opts: ClientOptions{ AuthToken: "token", Transport: http.DefaultTransport, }, out: true, }, { name: "Host specified", opts: ClientOptions{ Host: "test.com", }, out: true, }, { name: "AuthToken specified", opts: ClientOptions{ AuthToken: "token", }, out: true, }, { name: "UnixDomainSocket specified", opts: ClientOptions{ UnixDomainSocket: "socket", }, out: true, }, { name: "Transport specified", opts: ClientOptions{ Transport: http.DefaultTransport, }, out: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, tt.out, optionsNeedResolution(tt.opts)) }) } } func testConfig() string { return ` hosts: github.com: user: user1 oauth_token: abc123 git_protocol: ssh ` } func testConfigWithSocket() string { return ` http_unix_socket: socket hosts: github.com: user: user1 oauth_token: token git_protocol: ssh ` } go-gh-2.6.0/pkg/api/errors.go000066400000000000000000000104111457133626100157220ustar00rootroot00000000000000package api import ( "encoding/json" "fmt" "io" "net/http" "net/url" "strings" ) // 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 { Code string Field string Message string Resource 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) } // GraphQLError represents an error response from GitHub GraphQL API. type GraphQLError struct { Errors []GraphQLErrorItem } // GraphQLErrorItem stores additional information about an error response // returned from the GitHub GraphQL API. type GraphQLErrorItem struct { Message string Locations []struct { Line int Column int } Path []interface{} Extensions map[string]interface{} Type string } // Allow GraphQLError to satisfy error interface. func (gr *GraphQLError) Error() string { errorMessages := make([]string, 0, len(gr.Errors)) for _, e := range gr.Errors { msg := e.Message 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 GraphQLError is about a specific type on a specific path. // If the path argument ends with a ".", it will match all its subpaths. func (gr *GraphQLError) Match(expectType, expectPath string) bool { for _, e := range gr.Errors { if e.Type != expectType || !matchPath(e.pathString(), expectPath) { return false } } return true } func (ge GraphQLErrorItem) 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-2.6.0/pkg/api/errors_test.go000066400000000000000000000025771457133626100167770ustar00rootroot00000000000000package api import ( "testing" "github.com/stretchr/testify/assert" ) func TestGraphQLErrorMatch(t *testing.T) { tests := []struct { name string error GraphQLError kind string path string wantMatch bool }{ { name: "matches path and type", error: GraphQLError{Errors: []GraphQLErrorItem{ {Path: []interface{}{"repository", "issue"}, Type: "NOT_FOUND"}, }}, kind: "NOT_FOUND", path: "repository.issue", wantMatch: true, }, { name: "matches base path and type", error: GraphQLError{Errors: []GraphQLErrorItem{ {Path: []interface{}{"repository", "issue"}, Type: "NOT_FOUND"}, }}, kind: "NOT_FOUND", path: "repository.", wantMatch: true, }, { name: "does not match path but matches type", error: GraphQLError{Errors: []GraphQLErrorItem{ {Path: []interface{}{"repository", "issue"}, Type: "NOT_FOUND"}, }}, kind: "NOT_FOUND", path: "label.title", wantMatch: false, }, { name: "matches path but not type", error: GraphQLError{Errors: []GraphQLErrorItem{ {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-2.6.0/pkg/api/graphql_client.go000066400000000000000000000120231457133626100174030ustar00rootroot00000000000000package api import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" graphql "github.com/cli/shurcooL-graphql" ) // GraphQLClient wraps methods for the different types of // API requests that are supported by the server. type GraphQLClient struct { client *graphql.Client host string httpClient *http.Client } func DefaultGraphQLClient() (*GraphQLClient, error) { return NewGraphQLClient(ClientOptions{}) } // GraphQLClient builds a client to send requests to GitHub GraphQL API endpoints. // As part of the configuration a hostname, auth token, default set of headers, // and unix domain socket are resolved from the gh environment configuration. // These behaviors can be overridden using the opts argument. func NewGraphQLClient(opts ClientOptions) (*GraphQLClient, error) { if optionsNeedResolution(opts) { var err error opts, err = resolveOptions(opts) if err != nil { return nil, err } } httpClient, err := NewHTTPClient(opts) if err != nil { return nil, err } endpoint := graphQLEndpoint(opts.Host) return &GraphQLClient{ client: graphql.NewClient(endpoint, httpClient), host: endpoint, httpClient: httpClient, }, nil } // DoWithContext executes a GraphQL query request. // The response is populated into the response argument. func (c *GraphQLClient) DoWithContext(ctx context.Context, query string, variables map[string]interface{}, response interface{}) error { reqBody, err := json.Marshal(map[string]interface{}{"query": query, "variables": variables}) if err != nil { return err } req, err := http.NewRequestWithContext(ctx, "POST", c.host, bytes.NewBuffer(reqBody)) if err != nil { return err } resp, err := c.httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() success := resp.StatusCode >= 200 && resp.StatusCode < 300 if !success { return HandleHTTPError(resp) } if resp.StatusCode == http.StatusNoContent { return nil } body, err := io.ReadAll(resp.Body) if err != nil { return err } gr := graphQLResponse{Data: response} err = json.Unmarshal(body, &gr) if err != nil { return err } if len(gr.Errors) > 0 { return &GraphQLError{Errors: gr.Errors} } return nil } // Do wraps DoWithContext using context.Background. func (c *GraphQLClient) Do(query string, variables map[string]interface{}, response interface{}) error { return c.DoWithContext(context.Background(), query, variables, response) } // MutateWithContext executes a GraphQL mutation request. // The mutation string is derived from the mutation argument, and the // response is populated into it. // The mutation argument should be a pointer to struct that corresponds // to the GitHub GraphQL schema. // Provided input will be set as a variable named input. func (c *GraphQLClient) MutateWithContext(ctx context.Context, name string, m interface{}, variables map[string]interface{}) error { err := c.client.MutateNamed(ctx, name, m, variables) var graphQLErrs graphql.Errors if err != nil && errors.As(err, &graphQLErrs) { items := make([]GraphQLErrorItem, len(graphQLErrs)) for i, e := range graphQLErrs { items[i] = GraphQLErrorItem{ Message: e.Message, Locations: e.Locations, Path: e.Path, Extensions: e.Extensions, Type: e.Type, } } err = &GraphQLError{items} } return err } // Mutate wraps MutateWithContext using context.Background. func (c *GraphQLClient) Mutate(name string, m interface{}, variables map[string]interface{}) error { return c.MutateWithContext(context.Background(), name, m, variables) } // QueryWithContext executes a GraphQL query request, // The query string is derived from the query argument, and the // response is populated into it. // The query argument should be a pointer to struct that corresponds // to the GitHub GraphQL schema. func (c *GraphQLClient) QueryWithContext(ctx context.Context, name string, q interface{}, variables map[string]interface{}) error { err := c.client.QueryNamed(ctx, name, q, variables) var graphQLErrs graphql.Errors if err != nil && errors.As(err, &graphQLErrs) { items := make([]GraphQLErrorItem, len(graphQLErrs)) for i, e := range graphQLErrs { items[i] = GraphQLErrorItem{ Message: e.Message, Locations: e.Locations, Path: e.Path, Extensions: e.Extensions, Type: e.Type, } } err = &GraphQLError{items} } return err } // Query wraps QueryWithContext using context.Background. func (c *GraphQLClient) Query(name string, q interface{}, variables map[string]interface{}) error { return c.QueryWithContext(context.Background(), name, q, variables) } type graphQLResponse struct { Data interface{} Errors []GraphQLErrorItem } func graphQLEndpoint(host string) string { if isGarage(host) { return fmt.Sprintf("https://%s/api/graphql", host) } host = normalizeHostname(host) if isEnterprise(host) { return fmt.Sprintf("https://%s/api/graphql", host) } if strings.EqualFold(host, localhost) { return fmt.Sprintf("http://api.%s/graphql", host) } return fmt.Sprintf("https://api.%s/graphql", host) } go-gh-2.6.0/pkg/api/graphql_client_test.go000066400000000000000000000210731457133626100204470ustar00rootroot00000000000000package api import ( "context" "errors" "net/http" "testing" "time" "github.com/stretchr/testify/assert" "gopkg.in/h2non/gock.v1" ) func TestGraphQLClient(t *testing.T) { stubConfig(t, testConfig()) t.Cleanup(gock.Off) gock.New("https://api.github.com"). Post("/graphql"). MatchHeader("Authorization", "token abc123"). BodyString(`{"query":"QUERY","variables":{"var":"test"}}`). Reply(200). JSON(`{"data":{"viewer":{"login":"hubot"}}}`) client, err := DefaultGraphQLClient() assert.NoError(t, err) vars := map[string]interface{}{"var": "test"} res := struct{ Viewer struct{ Login string } }{} err = client.Do("QUERY", vars, &res) assert.NoError(t, err) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) assert.Equal(t, "hubot", res.Viewer.Login) } func TestGraphQLClientDoError(t *testing.T) { stubConfig(t, testConfig()) t.Cleanup(gock.Off) gock.New("https://api.github.com"). Post("/graphql"). MatchHeader("Authorization", "token abc123"). BodyString(`{"query":"QUERY","variables":null}`). Reply(200). JSON(`{"errors":[{"type":"NOT_FOUND","path":["organization"],"message":"Could not resolve to an Organization with the login of 'cli'."}]}`) client, err := DefaultGraphQLClient() assert.NoError(t, err) res := struct{ Organization struct{ Name string } }{} err = client.Do("QUERY", nil, &res) var graphQLErr *GraphQLError assert.True(t, errors.As(err, &graphQLErr)) assert.EqualError(t, graphQLErr, "GraphQL: Could not resolve to an Organization with the login of 'cli'. (organization)") assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) } func TestGraphQLClientQueryError(t *testing.T) { stubConfig(t, testConfig()) t.Cleanup(gock.Off) gock.New("https://api.github.com"). Post("/graphql"). MatchHeader("Authorization", "token abc123"). BodyString(`{"query":"query QUERY{organization{name}}"}`). Reply(200). JSON(`{"errors":[{"type":"NOT_FOUND","path":["organization"],"message":"Could not resolve to an Organization with the login of 'cli'."}]}`) client, err := DefaultGraphQLClient() assert.NoError(t, err) var res struct{ Organization struct{ Name string } } err = client.Query("QUERY", &res, nil) var graphQLErr *GraphQLError assert.True(t, errors.As(err, &graphQLErr)) assert.EqualError(t, graphQLErr, "GraphQL: Could not resolve to an Organization with the login of 'cli'. (organization)") assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) } func TestGraphQLClientMutateError(t *testing.T) { stubConfig(t, testConfig()) t.Cleanup(gock.Off) gock.New("https://api.github.com"). Post("/graphql"). MatchHeader("Authorization", "token abc123"). BodyString(`{"query":"mutation MUTATE($input:ID!){updateRepository{repository{name}}}","variables":{"input":"variables"}}`). Reply(200). JSON(`{"errors":[{"type":"NOT_FOUND","path":["organization"],"message":"Could not resolve to an Organization with the login of 'cli'."}]}`) client, err := DefaultGraphQLClient() assert.NoError(t, err) var mutation struct { UpdateRepository struct{ Repository struct{ Name string } } } variables := map[string]interface{}{"input": "variables"} err = client.Mutate("MUTATE", &mutation, variables) var graphQLErr *GraphQLError assert.True(t, errors.As(err, &graphQLErr)) assert.EqualError(t, graphQLErr, "GraphQL: Could not resolve to an Organization with the login of 'cli'. (organization)") assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) } func TestGraphQLClientDo(t *testing.T) { tests := []struct { name string host string 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, _ := NewGraphQLClient(ClientOptions{ Host: tt.host, AuthToken: "token", Transport: http.DefaultTransport, }) vars := map[string]interface{}{"var": "test"} res := struct{ Viewer struct{ Login string } }{} err := client.Do("QUERY", vars, &res) 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 TestGraphQLClientDoWithContext(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) { t.Cleanup(gock.Off) gock.New("https://api.github.com"). Post("/graphql"). BodyString(`{"query":"QUERY","variables":{"var":"test"}}`). Reply(200). JSON(`{}`) client, _ := NewGraphQLClient(ClientOptions{ Host: "github.com", AuthToken: "token", Transport: http.DefaultTransport, }) vars := map[string]interface{}{"var": "test"} res := struct{ Viewer struct{ Login string } }{} ctx := tt.getCtx() gotErr := client.DoWithContext(ctx, "QUERY", vars, &res) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) assert.EqualError(t, gotErr, tt.wantErrMsg) }) } } func TestGraphQLEndpoint(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 := graphQLEndpoint(tt.host) assert.Equal(t, tt.wantEndpoint, endpoint) }) } } go-gh-2.6.0/pkg/api/http_client.go000066400000000000000000000156071457133626100167370ustar00rootroot00000000000000package api import ( "fmt" "io" "net" "net/http" "os" "regexp" "runtime/debug" "strings" "time" "github.com/cli/go-gh/v2/pkg/asciisanitizer" "github.com/cli/go-gh/v2/pkg/config" "github.com/cli/go-gh/v2/pkg/term" "github.com/henvic/httpretty" "github.com/thlib/go-timezone-local/tzlocal" "golang.org/x/text/transform" ) 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 DefaultHTTPClient() (*http.Client, error) { return NewHTTPClient(ClientOptions{}) } // HTTPClient builds a client that can be passed to another library. // As part of the configuration a hostname, auth token, default set of headers, // and unix domain socket are resolved from the gh environment configuration. // These behaviors can be overridden using the opts argument. In this instance // providing opts.Host will not change the destination of your request as it is // the responsibility of the consumer to configure this. However, if opts.Host // does not match the request host, the auth token will not be added to the headers. // This is to protect against the case where tokens could be sent to an arbitrary // host. func NewHTTPClient(opts ClientOptions) (*http.Client, error) { if optionsNeedResolution(opts) { var err error opts, err = resolveOptions(opts) if err != nil { return nil, err } } transport := http.DefaultTransport if opts.UnixDomainSocket != "" { transport = newUnixDomainSocketRoundTripper(opts.UnixDomainSocket) } if opts.Transport != nil { transport = opts.Transport } transport = newSanitizerRoundTripper(transport) if opts.CacheDir == "" { opts.CacheDir = config.CacheDir() } 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}, nil } 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, } } type sanitizerRoundTripper struct { rt http.RoundTripper } func newSanitizerRoundTripper(rt http.RoundTripper) http.RoundTripper { return sanitizerRoundTripper{rt: rt} } func (srt sanitizerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { resp, err := srt.rt.RoundTrip(req) if err != nil || !jsonTypeRE.MatchString(resp.Header.Get(contentType)) { return resp, err } sanitizedReadCloser := struct { io.Reader io.Closer }{ Reader: transform.NewReader(resp.Body, &asciisanitizer.Sanitizer{JSON: true}), Closer: resp.Body, } resp.Body = sanitizedReadCloser return resp, err } func currentTimeZone() string { tz, err := tzlocal.RuntimeTZ() if err != nil { return "" } return tz } go-gh-2.6.0/pkg/api/http_client_test.go000066400000000000000000000133401457133626100177660ustar00rootroot00000000000000package api import ( "bytes" "fmt" "io" "net/http" "strings" "testing" "github.com/cli/go-gh/v2/pkg/config" "github.com/stretchr/testify/assert" "gopkg.in/h2non/gock.v1" ) func TestHTTPClient(t *testing.T) { stubConfig(t, testConfig()) t.Cleanup(gock.Off) gock.New("https://api.github.com"). Get("/some/test/path"). MatchHeader("Authorization", "token abc123"). Reply(200). JSON(`{"message": "success"}`) client, err := DefaultHTTPClient() assert.NoError(t, err) res, err := client.Get("https://api.github.com/some/test/path") assert.NoError(t, err) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) assert.Equal(t, 200, res.StatusCode) } func TestNewHTTPClient(t *testing.T) { reflectHTTP := tripper{ roundTrip: func(req *http.Request) (*http.Response, error) { 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 := 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 } func stubConfig(t *testing.T, cfgStr string) { t.Helper() old := config.Read config.Read = func(_ *config.Config) (*config.Config, error) { return config.ReadFromString(cfgStr), nil } t.Cleanup(func() { config.Read = old }) } func printPendingMocks(mocks []gock.Mock) string { paths := []string{} for _, mock := range mocks { paths = append(paths, mock.Request().URLStruct.String()) } return fmt.Sprintf("%d unmatched mocks: %s", len(paths), strings.Join(paths, ", ")) } go-gh-2.6.0/pkg/api/log_formatter.go000066400000000000000000000025371457133626100172640ustar00rootroot00000000000000package api import ( "bytes" "encoding/json" "fmt" "io" "strings" "github.com/cli/go-gh/v2/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-2.6.0/pkg/api/rest_client.go000066400000000000000000000114141457133626100167250ustar00rootroot00000000000000package api import ( "context" "encoding/json" "fmt" "io" "net/http" "strings" ) // RESTClient wraps methods for the different types of // API requests that are supported by the server. type RESTClient struct { client *http.Client host string } func DefaultRESTClient() (*RESTClient, error) { return NewRESTClient(ClientOptions{}) } // RESTClient builds a client to send requests to GitHub REST API endpoints. // As part of the configuration a hostname, auth token, default set of headers, // and unix domain socket are resolved from the gh environment configuration. // These behaviors can be overridden using the opts argument. func NewRESTClient(opts ClientOptions) (*RESTClient, error) { if optionsNeedResolution(opts) { var err error opts, err = resolveOptions(opts) if err != nil { return nil, err } } client, err := NewHTTPClient(opts) if err != nil { return nil, err } return &RESTClient{ client: client, host: opts.Host, }, nil } // RequestWithContext issues a request with type specified by method to the // specified path with the specified body. // The response is returned rather than being populated // into a response argument. func (c *RESTClient) RequestWithContext(ctx context.Context, method string, path string, body io.Reader) (*http.Response, error) { url := restURL(c.host, path) req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { return nil, err } resp, err := c.client.Do(req) if err != nil { return nil, err } success := resp.StatusCode >= 200 && resp.StatusCode < 300 if !success { defer resp.Body.Close() return nil, HandleHTTPError(resp) } return resp, err } // Request wraps RequestWithContext with context.Background. func (c *RESTClient) Request(method string, path string, body io.Reader) (*http.Response, error) { return c.RequestWithContext(context.Background(), method, path, body) } // DoWithContext issues a request with type specified by method to the // specified path with the specified body. // The response is populated into the response argument. func (c *RESTClient) DoWithContext(ctx context.Context, method string, path string, body io.Reader, response interface{}) error { url := restURL(c.host, path) req, err := http.NewRequestWithContext(ctx, method, url, body) if err != nil { return err } resp, err := c.client.Do(req) if err != nil { return err } success := resp.StatusCode >= 200 && resp.StatusCode < 300 if !success { defer resp.Body.Close() return HandleHTTPError(resp) } if resp.StatusCode == http.StatusNoContent { return nil } defer resp.Body.Close() b, err := io.ReadAll(resp.Body) if err != nil { return err } err = json.Unmarshal(b, &response) if err != nil { return err } return nil } // Do wraps DoWithContext with context.Background. func (c *RESTClient) Do(method string, path string, body io.Reader, response interface{}) error { return c.DoWithContext(context.Background(), method, path, body, response) } // Delete issues a DELETE request to the specified path. // The response is populated into the response argument. func (c *RESTClient) Delete(path string, resp interface{}) error { return c.Do(http.MethodDelete, path, nil, resp) } // Get issues a GET request to the specified path. // The response is populated into the response argument. func (c *RESTClient) Get(path string, resp interface{}) error { return c.Do(http.MethodGet, path, nil, resp) } // Patch issues a PATCH request to the specified path with the specified body. // The response is populated into the response argument. func (c *RESTClient) Patch(path string, body io.Reader, resp interface{}) error { return c.Do(http.MethodPatch, path, body, resp) } // Post issues a POST request to the specified path with the specified body. // The response is populated into the response argument. func (c *RESTClient) Post(path string, body io.Reader, resp interface{}) error { return c.Do(http.MethodPost, path, body, resp) } // Put issues a PUT request to the specified path with the specified body. // The response is populated into the response argument. func (c *RESTClient) Put(path string, body io.Reader, resp interface{}) error { return c.Do(http.MethodPut, path, body, resp) } func restURL(hostname string, pathOrURL string) string { if strings.HasPrefix(pathOrURL, "https://") || strings.HasPrefix(pathOrURL, "http://") { return pathOrURL } return restPrefix(hostname) + pathOrURL } func restPrefix(hostname string) string { if isGarage(hostname) { return fmt.Sprintf("https://%s/api/v3/", hostname) } hostname = normalizeHostname(hostname) if isEnterprise(hostname) { return fmt.Sprintf("https://%s/api/v3/", hostname) } if strings.EqualFold(hostname, localhost) { return fmt.Sprintf("http://api.%s/", hostname) } return fmt.Sprintf("https://api.%s/", hostname) } go-gh-2.6.0/pkg/api/rest_client_test.go000066400000000000000000000264771457133626100200030ustar00rootroot00000000000000package api import ( "bytes" "context" "io" "net/http" "testing" "time" "github.com/stretchr/testify/assert" "gopkg.in/h2non/gock.v1" ) func TestRESTClient(t *testing.T) { stubConfig(t, testConfig()) t.Cleanup(gock.Off) gock.New("https://api.github.com"). Get("/some/test/path"). MatchHeader("Authorization", "token abc123"). Reply(200). JSON(`{"message": "success"}`) client, err := DefaultRESTClient() assert.NoError(t, err) res := struct{ Message string }{} err = client.Do("GET", "some/test/path", nil, &res) assert.NoError(t, err) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) assert.Equal(t, "success", res.Message) } func TestRESTClientRequest(t *testing.T) { tests := []struct { name string 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(ClientOptions{ Host: tt.host, AuthToken: "token", Transport: http.DefaultTransport, }) 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(ClientOptions{ Host: tt.host, AuthToken: "token", Transport: http.DefaultTransport, }) 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(ClientOptions{ Host: "github.com", AuthToken: "token", Transport: http.DefaultTransport, }) err := client.Delete("some/path/here", nil) assert.NoError(t, err) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) } func TestRESTClientGet(t *testing.T) { t.Cleanup(gock.Off) gock.New("https://api.github.com"). Get("/some/path/here"). Reply(204). JSON(`{}`) client, _ := NewRESTClient(ClientOptions{ Host: "github.com", AuthToken: "token", Transport: http.DefaultTransport, }) err := client.Get("some/path/here", nil) assert.NoError(t, err) assert.True(t, gock.IsDone(), printPendingMocks(gock.Pending())) } 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(ClientOptions{ Host: "github.com", AuthToken: "token", Transport: http.DefaultTransport, }) r := bytes.NewReader([]byte(`{}`)) err := client.Patch("some/path/here", r, nil) assert.NoError(t, err) 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(ClientOptions{ Host: "github.com", AuthToken: "token", Transport: http.DefaultTransport, }) r := bytes.NewReader([]byte(`{}`)) err := client.Post("some/path/here", r, nil) assert.NoError(t, err) 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(ClientOptions{ Host: "github.com", AuthToken: "token", Transport: http.DefaultTransport, }) r := bytes.NewReader([]byte(`{}`)) err := client.Put("some/path/here", r, nil) assert.NoError(t, err) 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(ClientOptions{ Host: "github.com", AuthToken: "token", Transport: http.DefaultTransport, }) 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(ClientOptions{ Host: "github.com", AuthToken: "token", Transport: http.DefaultTransport, }) // 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) }) } } go-gh-2.6.0/pkg/asciisanitizer/000077500000000000000000000000001457133626100163325ustar00rootroot00000000000000go-gh-2.6.0/pkg/asciisanitizer/sanitizer.go000066400000000000000000000130061457133626100206710ustar00rootroot00000000000000// Package asciisanitizer implements an ASCII control character sanitizer for UTF-8 strings. // It will transform ASCII control codes into equivalent inert characters that are safe for display in the terminal. // Without sanitization these ASCII control characters will be interpreted by the terminal. // This behaviour can be used maliciously as an attack vector, especially the ASCII control characters \x1B and \x9B. package asciisanitizer import ( "bytes" "errors" "strings" "unicode" "unicode/utf8" "golang.org/x/text/transform" ) // Sanitizer implements transform.Transformer interface. type Sanitizer struct { // JSON tells the Sanitizer to replace strings that will be transformed // into control characters when the string is marshaled to JSON. Set to // true if the string being sanitized represents JSON formatted data. JSON bool addEscape bool } // Transform uses a sliding window algorithm to detect C0 and C1 control characters as they are read and replaces // them with equivalent inert characters. Bytes that are not part of a control character are not modified. func (t *Sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) { transfer := func(write, read []byte) error { readLength := len(read) writeLength := len(write) if writeLength > len(dst) { return transform.ErrShortDst } copy(dst, write) nDst += writeLength dst = dst[writeLength:] nSrc += readLength src = src[readLength:] return nil } for len(src) > 0 { // When sanitizing JSON strings make sure that we have 6 bytes if available. if t.JSON && len(src) < 6 && !atEOF { err = transform.ErrShortSrc return } r, size := utf8.DecodeRune(src) if r == utf8.RuneError && size < 2 { if !atEOF { err = transform.ErrShortSrc return } else { err = errors.New("invalid UTF-8 string") return } } // Replace C0 and C1 control characters. if unicode.IsControl(r) { if repl, found := mapControlToCaret(r); found { err = transfer(repl, src[:size]) if err != nil { return } continue } } // Replace JSON C0 and C1 control characters. if t.JSON && len(src) >= 6 { if repl, found := mapJSONControlToCaret(src[:6]); found { if t.addEscape { // Add an escape character when necessary to prevent creating // invalid JSON with our replacements. repl = append([]byte{'\\'}, repl...) t.addEscape = false } err = transfer(repl, src[:6]) if err != nil { return } continue } } err = transfer(src[:size], src[:size]) if err != nil { return } if t.JSON { if r == '\\' { t.addEscape = !t.addEscape } else { t.addEscape = false } } } return } // Reset resets the state and allows the Sanitizer to be reused. func (t *Sanitizer) Reset() { t.addEscape = false } // mapControlToCaret maps C0 and C1 control characters to their caret notation. func mapControlToCaret(r rune) ([]byte, bool) { //\t (09), \n (10), \v (11), \r (13) are safe C0 characters and are not sanitized. m := map[rune]string{ 0: `^@`, 1: `^A`, 2: `^B`, 3: `^C`, 4: `^D`, 5: `^E`, 6: `^F`, 7: `^G`, 8: `^H`, 12: `^L`, 14: `^N`, 15: `^O`, 16: `^P`, 17: `^Q`, 18: `^R`, 19: `^S`, 20: `^T`, 21: `^U`, 22: `^V`, 23: `^W`, 24: `^X`, 25: `^Y`, 26: `^Z`, 27: `^[`, 28: `^\\`, 29: `^]`, 30: `^^`, 31: `^_`, 128: `^@`, 129: `^A`, 130: `^B`, 131: `^C`, 132: `^D`, 133: `^E`, 134: `^F`, 135: `^G`, 136: `^H`, 137: `^I`, 138: `^J`, 139: `^K`, 140: `^L`, 141: `^M`, 142: `^N`, 143: `^O`, 144: `^P`, 145: `^Q`, 146: `^R`, 147: `^S`, 148: `^T`, 149: `^U`, 150: `^V`, 151: `^W`, 152: `^X`, 153: `^Y`, 154: `^Z`, 155: `^[`, 156: `^\\`, 157: `^]`, 158: `^^`, 159: `^_`, } if c, ok := m[r]; ok { return []byte(c), true } return nil, false } // mapJSONControlToCaret maps JSON C0 and C1 control characters to their caret notation. // JSON control characters are six byte strings, representing a unicode code point, // ranging from \u0000 to \u001F and \u0080 to \u009F. func mapJSONControlToCaret(b []byte) ([]byte, bool) { if len(b) != 6 { return nil, false } if !bytes.HasPrefix(b, []byte(`\u00`)) { return nil, false } //\t (\u0009), \n (\u000a), \v (\u000b), \r (\u000d) are safe C0 characters and are not sanitized. m := map[string]string{ `\u0000`: `^@`, `\u0001`: `^A`, `\u0002`: `^B`, `\u0003`: `^C`, `\u0004`: `^D`, `\u0005`: `^E`, `\u0006`: `^F`, `\u0007`: `^G`, `\u0008`: `^H`, `\u000c`: `^L`, `\u000e`: `^N`, `\u000f`: `^O`, `\u0010`: `^P`, `\u0011`: `^Q`, `\u0012`: `^R`, `\u0013`: `^S`, `\u0014`: `^T`, `\u0015`: `^U`, `\u0016`: `^V`, `\u0017`: `^W`, `\u0018`: `^X`, `\u0019`: `^Y`, `\u001a`: `^Z`, `\u001b`: `^[`, `\u001c`: `^\\`, `\u001d`: `^]`, `\u001e`: `^^`, `\u001f`: `^_`, `\u0080`: `^@`, `\u0081`: `^A`, `\u0082`: `^B`, `\u0083`: `^C`, `\u0084`: `^D`, `\u0085`: `^E`, `\u0086`: `^F`, `\u0087`: `^G`, `\u0088`: `^H`, `\u0089`: `^I`, `\u008a`: `^J`, `\u008b`: `^K`, `\u008c`: `^L`, `\u008d`: `^M`, `\u008e`: `^N`, `\u008f`: `^O`, `\u0090`: `^P`, `\u0091`: `^Q`, `\u0092`: `^R`, `\u0093`: `^S`, `\u0094`: `^T`, `\u0095`: `^U`, `\u0096`: `^V`, `\u0097`: `^W`, `\u0098`: `^X`, `\u0099`: `^Y`, `\u009a`: `^Z`, `\u009b`: `^[`, `\u009c`: `^\\`, `\u009d`: `^]`, `\u009e`: `^^`, `\u009f`: `^_`, } if c, ok := m[strings.ToLower(string(b))]; ok { return []byte(c), true } return nil, false } go-gh-2.6.0/pkg/asciisanitizer/sanitizer_test.go000066400000000000000000000066251457133626100217410ustar00rootroot00000000000000package asciisanitizer import ( "bytes" "testing" "testing/iotest" "github.com/stretchr/testify/require" "golang.org/x/text/transform" ) func TestSanitizerTransform(t *testing.T) { tests := []struct { name string json bool input string want string }{ { name: "No control characters", input: "The quick brown fox jumped over the lazy dog", want: "The quick brown fox jumped over the lazy dog", }, { name: "JSON sanitization maintains valid JSON", json: true, input: `\u001B \\u001B \\\u001B \\\\u001B \\u001B\\u001B`, want: `^[ \\^[ \\^[ \\\\^[ \\^[\\^[`, }, { name: "JSON C0 control character", json: true, input: `0\u0000`, want: "0^@", }, { name: "JSON C0 control characters", json: true, input: `0\u0000 1\u0001 2\u0002 3\u0003 4\u0004 5\u0005 6\u0006 7\u0007 8\u0008 9\u0009 ` + `A\u000a B\u000b C\u000c D\u000d E\u000e F\u000f ` + `10\u0010 11\u0011 12\u0012 13\u0013 14\u0014 15\u0015 16\u0016 17\u0017 18\u0018 19\u0019 ` + `1A\u001a 1B\u001b 1C\u001c 1D\u001d 1E\u001e 1F\u001f`, want: `0^@ 1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\u0009 ` + `A\u000a B\u000b C^L D\u000d E^N F^O ` + `10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y ` + `1A^Z 1B^[ 1C^\\ 1D^] 1E^^ 1F^_`, }, { name: "JSON C1 control characters", json: true, input: `80\u0080 81\u0081 82\u0082 83\u0083 84\u0084 85\u0085 86\u0086 87\u0087 88\u0088 89\u0089 ` + `8A\u008a 8B\u008b 8C\u008c 8D\u008d 8E\u008e 8F\u008f ` + `90\u0090 91\u0091 92\u0092 93\u0093 94\u0094 95\u0095 96\u0096 97\u0097 98\u0098 99\u0099 ` + `9A\u009a 9B\u009b 9C\u009c 9D\u009d 9E\u009e 9F\u009f`, want: `80^@ 81^A 82^B 83^C 84^D 85^E 86^F 87^G 88^H 89^I ` + `8A^J 8B^K 8C^L 8D^M 8E^N 8F^O ` + `90^P 91^Q 92^R 93^S 94^T 95^U 96^V 97^W 98^X 99^Y ` + `9A^Z 9B^[ 9C^\\ 9D^] 9E^^ 9F^_`, }, { name: "C0 control character", input: "0\x00", want: "0^@", }, { name: "C0 control characters", input: "0\x00 1\x01 2\x02 3\x03 4\x04 5\x05 6\x06 7\x07 8\x08 9\x09 " + "A\x0A B\x0B C\x0C D\x0D E\x0E F\x0F " + "10\x10 11\x11 12\x12 13\x13 14\x14 15\x15 16\x16 17\x17 18\x18 19\x19 " + "1A\x1A 1B\x1B 1C\x1C 1D\x1D 1E\x1E 1F\x1F", want: "0^@ 1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\t " + "A\n B\v C^L D\r E^N F^O " + "10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y " + "1A^Z 1B^[ 1C^\\\\ 1D^] 1E^^ 1F^_", }, { name: "C1 control character", input: "80\xC2\x80", want: "80^@", }, { name: "C1 control characters", input: "80\xC2\x80 81\xC2\x81 82\xC2\x82 83\xC2\x83 84\xC2\x84 85\xC2\x85 86\xC2\x86 87\xC2\x87 88\xC2\x88 89\xC2\x89 " + "8A\xC2\x8A 8B\xC2\x8B 8C\xC2\x8C 8D\xC2\x8D 8E\xC2\x8E 8F\xC2\x8F " + "90\xC2\x90 91\xC2\x91 92\xC2\x92 93\xC2\x93 94\xC2\x94 95\xC2\x95 96\xC2\x96 97\xC2\x97 98\xC2\x98 99\xC2\x99 " + "9A\xC2\x9A 9B\xC2\x9B 9C\xC2\x9C 9D\xC2\x9D 9E\xC2\x9E 9F\xC2\x9F", want: "80^@ 81^A 82^B 83^C 84^D 85^E 86^F 87^G 88^H 89^I " + "8A^J 8B^K 8C^L 8D^M 8E^N 8F^O " + "90^P 91^Q 92^R 93^S 94^T 95^U 96^V 97^W 98^X 99^Y " + "9A^Z 9B^[ 9C^\\\\ 9D^] 9E^^ 9F^_", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { sanitizer := &Sanitizer{JSON: tt.json} reader := bytes.NewReader([]byte(tt.input)) transformReader := transform.NewReader(reader, sanitizer) err := iotest.TestReader(transformReader, []byte(tt.want)) require.NoError(t, err) }) } } go-gh-2.6.0/pkg/auth/000077500000000000000000000000001457133626100142525ustar00rootroot00000000000000go-gh-2.6.0/pkg/auth/auth.go000066400000000000000000000123361457133626100155470ustar00rootroot00000000000000// Package auth is a set of functions for retrieving authentication tokens // and authenticated hosts. package auth import ( "fmt" "os" "os/exec" "strconv" "strings" "github.com/cli/go-gh/v2/internal/set" "github.com/cli/go-gh/v2/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 } ghExe := os.Getenv("GH_PATH") if ghExe == "" { ghExe, _ = safeexec.LookPath("gh") } if ghExe != "" { 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(nil) 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(nil) 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(nil) 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 } // TenancyHost is the domain name of a tenancy GitHub instance. const tenancyHost = "ghe.com" func isEnterprise(host string) bool { return host != github && host != localhost && !isTenancy(host) } func isTenancy(host string) bool { return strings.HasSuffix(host, "."+tenancyHost) } func normalizeHostname(host string) string { hostname := strings.ToLower(host) if strings.HasSuffix(hostname, "."+github) { return github } if strings.HasSuffix(hostname, "."+localhost) { return localhost } // This has been copied over from the cli/cli NormalizeHostname function // to ensure compatible behaviour but we don't fully understand when or // why it would be useful here. We can't see what harm will come of // duplicating the logic. if before, found := cutSuffix(hostname, "."+tenancyHost); found { idx := strings.LastIndex(before, ".") return fmt.Sprintf("%s.%s", before[idx+1:], tenancyHost) } return hostname } // Backport strings.CutSuffix from Go 1.20. func cutSuffix(s, suffix string) (string, bool) { if !strings.HasSuffix(s, suffix) { return s, false } return s[:len(s)-len(suffix)], true } go-gh-2.6.0/pkg/auth/auth_test.go000066400000000000000000000202721457133626100166040ustar00rootroot00000000000000package auth import ( "testing" "github.com/cli/go-gh/v2/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", }, { name: "token for tenant with GH_TOKEN, GITHUB_TOKEN, and config token", host: "tenant.ghe.com", ghToken: "GH_TOKEN", githubToken: "GITHUB_TOKEN", config: testHostsConfig(), wantToken: "GH_TOKEN", wantSource: "GH_TOKEN", }, { name: "token for tenant with GITHUB_TOKEN, and config token", host: "tenant.ghe.com", githubToken: "GITHUB_TOKEN", config: testHostsConfig(), wantToken: "GITHUB_TOKEN", wantSource: "GITHUB_TOKEN", }, { name: "token for tenant with config token", host: "tenant.ghe.com", config: testHostsConfig(), wantToken: "zzzzzzzzzzzzzzzzzzzz", 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", "tenant.ghe.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", "tenant.ghe.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, }, { name: "tenant", host: "tenant.ghe.com", wantOut: false, }, } 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", }, { name: "bare tenant", host: "tenant.ghe.com", wantHost: "tenant.ghe.com", }, { name: "subdomained tenant", host: "api.tenant.ghe.com", wantHost: "tenant.ghe.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 tenant.ghe.com: user: user3 oauth_token: zzzzzzzzzzzzzzzzzzzz git_protocol: https ` return config.ReadFromString(data) } go-gh-2.6.0/pkg/browser/000077500000000000000000000000001457133626100147745ustar00rootroot00000000000000go-gh-2.6.0/pkg/browser/browser.go000066400000000000000000000035061457133626100170120ustar00rootroot00000000000000// 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/v2/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(nil) if err == nil { if cfgBrowser, _ := cfg.Get([]string{"browser"}); cfgBrowser != "" { return cfgBrowser } } return os.Getenv("BROWSER") } go-gh-2.6.0/pkg/browser/browser_test.go000066400000000000000000000044401457133626100200470ustar00rootroot00000000000000package browser import ( "bytes" "fmt" "os" "testing" "github.com/cli/go-gh/v2/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) (*config.Config, error) { return tt.config, nil } defer func() { config.Read = old }() } launcher := resolveLauncher() assert.Equal(t, tt.wantLauncher, launcher) }) } } go-gh-2.6.0/pkg/config/000077500000000000000000000000001457133626100145565ustar00rootroot00000000000000go-gh-2.6.0/pkg/config/config.go000066400000000000000000000205221457133626100163530ustar00rootroot00000000000000// 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/v2/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. If the string value to be set is empty it will be // represented as null not an empty string when written. // // var c *Config // c.Set([]string{"key"}, "") // Write(c) // writes `key: ` not `key: ""` 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 } val := yamlmap.StringValue(value) if value == "" { val = yamlmap.NullValue() } m.SetEntry(keys[len(keys)-1], val) } func (c *Config) deepCopy() *Config { return ReadFromString(c.entries.String()) } // Read gh configuration files from the local file system and // returns a Config. A copy of the fallback configuration will // be returned when there are no configuration files to load. // If there are no configuration files and no fallback configuration // an empty configuration will be returned. var Read = func(fallback *Config) (*Config, error) { once.Do(func() { cfg, loadErr = load(generalConfigFile(), hostsConfigFile(), fallback) }) return cfg, loadErr } // ReadFromString takes a yaml string and returns a Config. 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, fallback *Config) (*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 = yamlmap.MapValue() } 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) generalMap.SetUnmodified() } if generalMap.Empty() && fallback != nil { return fallback.deepCopy(), nil } 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 } // CacheDir returns the default path for gh cli cache. func CacheDir() string { return filepath.Join(os.TempDir(), "gh-cli-cache") } 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) (writeErr error) { if writeErr = os.MkdirAll(filepath.Dir(filename), 0771); writeErr != nil { return } var file *os.File if file, writeErr = os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600); writeErr != nil { return } defer func() { if err := file.Close(); writeErr == nil && err != nil { writeErr = err } }() _, writeErr = file.Write(data) return } go-gh-2.6.0/pkg/config/config_test.go000066400000000000000000000414621457133626100174200ustar00rootroot00000000000000package config import ( "fmt" "os" "path/filepath" "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) 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 TestCacheDir(t *testing.T) { expected := filepath.Join(os.TempDir(), "gh-cli-cache") actual := CacheDir() assert.Equal(t, expected, actual) } 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 fallback *Config wantGitProtocol string wantToken string wantErr bool wantErrMsg string }{ { 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: "", wantToken: "yyyyyyyyyyyyyyyyyyyy", }, { name: "global file exist and hosts file does not exist", globalConfigPath: globalFilePath, hostsConfigPath: "", wantGitProtocol: "ssh", wantToken: "", }, { name: "global file does not exist and hosts file does not exist with no fallback", globalConfigPath: "", hostsConfigPath: "", wantGitProtocol: "", wantToken: "", }, { name: "global file does not exist and hosts file does not exist with fallback", globalConfigPath: "", hostsConfigPath: "", fallback: ReadFromString(testFullConfig()), wantGitProtocol: "ssh", wantToken: "yyyyyyyyyyyyyyyyyyyy", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg, err := load(tt.globalConfigPath, tt.hostsConfigPath, tt.fallback) if tt.wantErr { assert.EqualError(t, err, tt.wantErrMsg) return } assert.NoError(t, err) if tt.wantGitProtocol == "" { assertNoKey(t, cfg, []string{"git_protocol"}) } else { assertKeyWithValue(t, cfg, []string{"git_protocol"}, tt.wantGitProtocol) } if tt.wantToken == "" { assertNoKey(t, cfg, []string{"hosts", "enterprise.com", "oauth_token"}) } else { assertKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "oauth_token"}, tt.wantToken) } if tt.fallback != nil { // Assert that load returns an equivalent copy of fallvback. assert.Equal(t, tt.fallback.entries.String(), cfg.entries.String()) assert.False(t, tt.fallback == cfg) } }) } } 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 }, wantConfig: func() *Config { // Same as created config as both a global property and host property has // been edited. 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 global config file. cfg := ReadFromString("") 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 global config file", createConfig: func() *Config { cfg := ReadFromString(testFullConfig()) cfg.Set([]string{"editor"}, "vim") return cfg }, wantConfig: func() *Config { // The global config file is written but not the hosts config file. cfg := ReadFromString(testGlobalData()) cfg.Set([]string{"editor"}, "vim") 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(), nil) assert.NoError(t, err) wantCfg := tt.wantConfig() assert.Equal(t, wantCfg.entries.String(), loadedCfg.entries.String()) }) } } func TestWriteEmptyValues(t *testing.T) { tempDir := t.TempDir() t.Setenv("GH_CONFIG_DIR", tempDir) cfg := ReadFromString(testFullConfig()) cfg.Set([]string{"editor"}, "") err := Write(cfg) assert.NoError(t, err) data, err := os.ReadFile(generalConfigFile()) assert.NoError(t, err) assert.Equal(t, "git_protocol: ssh\neditor:\nprompt: enabled\npager: less\n", string(data)) } func TestGet(t *testing.T) { tests := []struct { name string keys []string wantValue string wantErr bool }{ { 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, }, { 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, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := testConfig() if tt.wantErr { assertNoKey(t, cfg, tt.keys) } else { assertKeyWithValue(t, cfg, tt.keys, tt.wantValue) } 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()) } assertNoKey(t, cfg, tt.keys) }) } } 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", }, { name: "set empty value", keys: []string{"empty"}, value: "", }, } 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()) assertKeyWithValue(t, cfg, tt.keys, tt.value) }) } } func TestEntriesShouldBeModifiedOnLoad(t *testing.T) { // Given we have a persisted config and hosts file tempDir := t.TempDir() t.Setenv("GH_CONFIG_DIR", tempDir) require.NoError(t, writeFile(hostsConfigFile(), []byte(testHostsData()))) require.NoError(t, writeFile(generalConfigFile(), []byte(testGlobalData()))) // When we load that config cfg, err := load(generalConfigFile(), hostsConfigFile(), nil) require.NoError(t, err) // Then the general and host entries should be unmodified // because we didn't mutate anything yet require.False(t, cfg.entries.IsModified()) hosts, err := cfg.entries.FindEntry("hosts") require.NoError(t, err) require.False(t, hosts.IsModified()) } 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 } func assertNoKey(t *testing.T, cfg *Config, keys []string) { t.Helper() _, err := cfg.Get(keys) var keyNotFoundError *KeyNotFoundError assert.ErrorAs(t, err, &keyNotFoundError) } func assertKeyWithValue(t *testing.T, cfg *Config, keys []string, value string) { t.Helper() actual, err := cfg.Get(keys) assert.NoError(t, err) assert.Equal(t, value, actual) } go-gh-2.6.0/pkg/config/errors.go000066400000000000000000000014021457133626100164160ustar00rootroot00000000000000package 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-2.6.0/pkg/jq/000077500000000000000000000000001457133626100137235ustar00rootroot00000000000000go-gh-2.6.0/pkg/jq/jq.go000066400000000000000000000053641457133626100146740ustar00rootroot00000000000000// Package jq facilitates processing of JSON strings using jq expressions. package jq import ( "bytes" "encoding/json" "fmt" "io" "math" "os" "strconv" "github.com/cli/go-gh/v2/pkg/jsonpretty" "github.com/itchyny/gojq" ) // Evaluate a jq expression against an input and write it to an output. // Any top-level scalar values produced by the jq expression are written out // directly, as raw values and not as JSON scalars, similar to how jq --raw // works. func Evaluate(input io.Reader, output io.Writer, expr string) error { return EvaluateFormatted(input, output, expr, "", false) } // Evaluate a jq expression against an input and write it to an output, // optionally with indentation and colorization. Any top-level scalar values // produced by the jq expression are written out directly, as raw values and not // as JSON scalars, similar to how jq --raw works. func EvaluateFormatted(input io.Reader, output io.Writer, expr string, indent string, colorize bool) 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 := prettyEncoder{ w: output, indent: indent, colorize: colorize, } 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 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) } } type prettyEncoder struct { w io.Writer indent string colorize bool } func (p prettyEncoder) Encode(v any) error { var b []byte var err error if p.indent == "" { b, err = json.Marshal(v) } else { b, err = json.MarshalIndent(v, "", p.indent) } if err != nil { return err } if !p.colorize { if _, err := p.w.Write(b); err != nil { return err } if _, err := p.w.Write([]byte{'\n'}); err != nil { return err } return nil } return jsonpretty.Format(p.w, bytes.NewReader(b), p.indent, true) } go-gh-2.6.0/pkg/jq/jq_test.go000066400000000000000000000076241457133626100157340ustar00rootroot00000000000000package jq import ( "bytes" "io" "strings" "testing" "github.com/MakeNowJust/heredoc" "github.com/stretchr/testify/assert" ) func TestEvaluateFormatted(t *testing.T) { t.Setenv("CODE", "code_c") type args struct { json io.Reader expr string indent string colorize bool } tests := []struct { name string args args wantW string wantErr bool }{ { name: "simple", args: args{ json: strings.NewReader(`{"name":"Mona", "arms":8}`), expr: `.name`, indent: "", colorize: false, }, wantW: "Mona\n", }, { name: "multiple queries", args: args{ json: strings.NewReader(`{"name":"Mona", "arms":8}`), expr: `.name,.arms`, indent: "", colorize: false, }, wantW: "Mona\n8\n", }, { name: "object as JSON", args: args{ json: strings.NewReader(`{"user":{"login":"monalisa"}}`), expr: `.user`, indent: "", colorize: false, }, wantW: "{\"login\":\"monalisa\"}\n", }, { name: "object as JSON, indented", args: args{ json: strings.NewReader(`{"user":{"login":"monalisa"}}`), expr: `.user`, indent: " ", colorize: false, }, wantW: "{\n \"login\": \"monalisa\"\n}\n", }, { name: "object as JSON, indented & colorized", args: args{ json: strings.NewReader(`{"user":{"login":"monalisa"}}`), expr: `.user`, indent: " ", colorize: true, }, wantW: "\x1b[1;38m{\x1b[m\n" + " \x1b[1;34m\"login\"\x1b[m\x1b[1;38m:\x1b[m" + " \x1b[32m\"monalisa\"\x1b[m\n" + "\x1b[1;38m}\x1b[m\n", }, { name: "empty array", args: args{ json: strings.NewReader(`[]`), expr: `., [], unique`, indent: "", colorize: false, }, wantW: "[]\n[]\n[]\n", }, { name: "empty array, colorized", args: args{ json: strings.NewReader(`[]`), expr: `.`, indent: "", colorize: true, }, wantW: "\x1b[1;38m[\x1b[m\x1b[1;38m]\x1b[m\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`, indent: "", colorize: false, }, 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`, indent: " ", colorize: false, }, wantW: "[\n {},\n {\n \"name\": \"feature\"\n }\n]\n", }, { name: "mixing scalars, arrays and objects", args: args{ json: strings.NewReader(heredoc.Doc(`[ "foo", true, 42, [17, 23], {"foo": "bar"} ]`)), expr: `.[]`, indent: " ", colorize: true, }, wantW: "foo\ntrue\n42\n" + "\x1b[1;38m[\x1b[m\n" + " 17\x1b[1;38m,\x1b[m\n" + " 23\n" + "\x1b[1;38m]\x1b[m\n" + "\x1b[1;38m{\x1b[m\n" + " \x1b[1;34m\"foo\"\x1b[m\x1b[1;38m:\x1b[m" + " \x1b[32m\"bar\"\x1b[m\n" + "\x1b[1;38m}\x1b[m\n", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { w := &bytes.Buffer{} err := EvaluateFormatted(tt.args.json, w, tt.args.expr, tt.args.indent, tt.args.colorize) if tt.wantErr { assert.Error(t, err) return } assert.NoError(t, err) assert.Equal(t, tt.wantW, w.String()) }) } } go-gh-2.6.0/pkg/jsonpretty/000077500000000000000000000000001457133626100155325ustar00rootroot00000000000000go-gh-2.6.0/pkg/jsonpretty/format.go000066400000000000000000000056311457133626100173560ustar00rootroot00000000000000// 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-2.6.0/pkg/jsonpretty/format_test.go000066400000000000000000000043171457133626100204150ustar00rootroot00000000000000package 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-2.6.0/pkg/markdown/000077500000000000000000000000001457133626100151335ustar00rootroot00000000000000go-gh-2.6.0/pkg/markdown/markdown.go000066400000000000000000000036551457133626100173150ustar00rootroot00000000000000// 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 environment 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-2.6.0/pkg/markdown/markdown_test.go000066400000000000000000000011221457133626100203370ustar00rootroot00000000000000package 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-2.6.0/pkg/prompter/000077500000000000000000000000001457133626100151615ustar00rootroot00000000000000go-gh-2.6.0/pkg/prompter/mock.go000066400000000000000000000146611457133626100164510ustar00rootroot00000000000000package prompter import ( "fmt" "strings" "testing" "github.com/stretchr/testify/assert" ) // PrompterMock provides stubbed out methods for prompting the user for // use in tests. PrompterMock has a superset of the methods on Prompter // so they both can satisfy the same interface. // // A basic example of how PrompterMock can be used: // // type ConfirmPrompter interface { // Confirm(string, bool) (bool, error) // } // // func PlayGame(prompter ConfirmPrompter) (int, error) { // confirm, err := prompter.Confirm("Shall we play a game", true) // if err != nil { // return 0, err // } // if confirm { // return 1, nil // } // return 2, nil // } // // func TestPlayGame(t *testing.T) { // expectedOutcome := 1 // mock := NewMock(t) // mock.RegisterConfirm("Shall we play a game", func(prompt string, defaultValue bool) (bool, error) { // return true, nil // }) // outcome, err := PlayGame(mock) // if err != nil { // t.Fatalf("unexpected error: %v", err) // } // if outcome != expectedOutcome { // t.Errorf("expected %q, got %q", expectedOutcome, outcome) // } // } type PrompterMock struct { t *testing.T selectStubs []selectStub multiSelectStubs []multiSelectStub inputStubs []inputStub passwordStubs []passwordStub confirmStubs []confirmStub } type selectStub struct { prompt string expectedOptions []string fn func(string, string, []string) (int, error) } type multiSelectStub struct { prompt string expectedOptions []string fn func(string, []string, []string) ([]int, error) } type inputStub struct { prompt string fn func(string, string) (string, error) } type passwordStub struct { prompt string fn func(string) (string, error) } type confirmStub struct { Prompt string Fn func(string, bool) (bool, error) } // NewMock instantiates a new PrompterMock. func NewMock(t *testing.T) *PrompterMock { m := &PrompterMock{ t: t, selectStubs: []selectStub{}, multiSelectStubs: []multiSelectStub{}, inputStubs: []inputStub{}, passwordStubs: []passwordStub{}, confirmStubs: []confirmStub{}, } t.Cleanup(m.verify) return m } // Select prompts the user to select an option from a list of options. func (m *PrompterMock) Select(prompt, defaultValue string, options []string) (int, error) { var s selectStub if len(m.selectStubs) == 0 { return -1, noSuchPromptErr(prompt) } s = m.selectStubs[0] m.selectStubs = m.selectStubs[1:len(m.selectStubs)] if s.prompt != prompt { return -1, noSuchPromptErr(prompt) } assertOptions(m.t, s.expectedOptions, options) return s.fn(prompt, defaultValue, options) } // MultiSelect prompts the user to select multiple options from a list of options. func (m *PrompterMock) MultiSelect(prompt string, defaultValues, options []string) ([]int, error) { var s multiSelectStub if len(m.multiSelectStubs) == 0 { return []int{}, noSuchPromptErr(prompt) } s = m.multiSelectStubs[0] m.multiSelectStubs = m.multiSelectStubs[1:len(m.multiSelectStubs)] if s.prompt != prompt { return []int{}, noSuchPromptErr(prompt) } assertOptions(m.t, s.expectedOptions, options) return s.fn(prompt, defaultValues, options) } // Input prompts the user to input a single-line string. func (m *PrompterMock) Input(prompt, defaultValue string) (string, error) { var s inputStub if len(m.inputStubs) == 0 { return "", noSuchPromptErr(prompt) } s = m.inputStubs[0] m.inputStubs = m.inputStubs[1:len(m.inputStubs)] if s.prompt != prompt { return "", noSuchPromptErr(prompt) } return s.fn(prompt, defaultValue) } // Password prompts the user to input a single-line string without echoing the input. func (m *PrompterMock) Password(prompt string) (string, error) { var s passwordStub if len(m.passwordStubs) == 0 { return "", noSuchPromptErr(prompt) } s = m.passwordStubs[0] m.passwordStubs = m.passwordStubs[1:len(m.passwordStubs)] if s.prompt != prompt { return "", noSuchPromptErr(prompt) } return s.fn(prompt) } // Confirm prompts the user to confirm a yes/no question. func (m *PrompterMock) Confirm(prompt string, defaultValue bool) (bool, error) { var s confirmStub if len(m.confirmStubs) == 0 { return false, noSuchPromptErr(prompt) } s = m.confirmStubs[0] m.confirmStubs = m.confirmStubs[1:len(m.confirmStubs)] if s.Prompt != prompt { return false, noSuchPromptErr(prompt) } return s.Fn(prompt, defaultValue) } // RegisterSelect records that a Select prompt should be called. func (m *PrompterMock) RegisterSelect(prompt string, opts []string, stub func(_, _ string, _ []string) (int, error)) { m.selectStubs = append(m.selectStubs, selectStub{ prompt: prompt, expectedOptions: opts, fn: stub}) } // RegisterMultiSelect records that a MultiSelect prompt should be called. func (m *PrompterMock) RegisterMultiSelect(prompt string, d, opts []string, stub func(_ string, _, _ []string) ([]int, error)) { m.multiSelectStubs = append(m.multiSelectStubs, multiSelectStub{ prompt: prompt, expectedOptions: opts, fn: stub}) } // RegisterInput records that an Input prompt should be called. func (m *PrompterMock) RegisterInput(prompt string, stub func(_, _ string) (string, error)) { m.inputStubs = append(m.inputStubs, inputStub{prompt: prompt, fn: stub}) } // RegisterPassword records that a Password prompt should be called. func (m *PrompterMock) RegisterPassword(prompt string, stub func(string) (string, error)) { m.passwordStubs = append(m.passwordStubs, passwordStub{prompt: prompt, fn: stub}) } // RegisterConfirm records that a Confirm prompt should be called. func (m *PrompterMock) RegisterConfirm(prompt string, stub func(_ string, _ bool) (bool, error)) { m.confirmStubs = append(m.confirmStubs, confirmStub{Prompt: prompt, Fn: stub}) } func (m *PrompterMock) verify() { errs := []string{} if len(m.selectStubs) > 0 { errs = append(errs, "MultiSelect") } if len(m.multiSelectStubs) > 0 { errs = append(errs, "Select") } if len(m.inputStubs) > 0 { errs = append(errs, "Input") } if len(m.passwordStubs) > 0 { errs = append(errs, "Password") } if len(m.confirmStubs) > 0 { errs = append(errs, "Confirm") } if len(errs) > 0 { m.t.Helper() m.t.Errorf("%d unmatched calls to %s", len(errs), strings.Join(errs, ",")) } } func noSuchPromptErr(prompt string) error { return fmt.Errorf("no such prompt '%s'", prompt) } func assertOptions(t *testing.T, expected, actual []string) { assert.Equal(t, expected, actual) } go-gh-2.6.0/pkg/prompter/prompter.go000066400000000000000000000066351457133626100173720ustar00rootroot00000000000000// Package prompter provides various methods for prompting the user with // questions for input. package prompter import ( "fmt" "io" "strings" "github.com/AlecAivazis/survey/v2" "github.com/cli/go-gh/v2/pkg/text" ) // Prompter provides methods for prompting the user. type Prompter struct { stdin FileReader stdout FileWriter stderr FileWriter } // FileWriter provides a minimal writable interface for stdout and stderr. type FileWriter interface { io.Writer Fd() uintptr } // FileReader provides a minimal readable interface for stdin. type FileReader interface { io.Reader Fd() uintptr } // New instantiates a new Prompter. func New(stdin FileReader, stdout FileWriter, stderr FileWriter) *Prompter { return &Prompter{ stdin: stdin, stdout: stdout, stderr: stderr, } } // Select prompts the user to select an option from a list of options. func (p *Prompter) Select(prompt, defaultValue string, options []string) (int, error) { var result int q := &survey.Select{ Message: prompt, Options: options, PageSize: 20, Filter: latinMatchingFilter, } if defaultValue != "" { for _, o := range options { if o == defaultValue { q.Default = defaultValue break } } } err := p.ask(q, &result) return result, err } // MultiSelect prompts the user to select multiple options from a list of options. func (p *Prompter) MultiSelect(prompt string, defaultValues, options []string) ([]int, error) { var result []int q := &survey.MultiSelect{ Message: prompt, Options: options, PageSize: 20, Filter: latinMatchingFilter, } if len(defaultValues) > 0 { validatedDefault := []string{} for _, x := range defaultValues { for _, y := range options { if x == y { validatedDefault = append(validatedDefault, x) } } } q.Default = validatedDefault } err := p.ask(q, &result) return result, err } // Input prompts the user to input a single-line string. func (p *Prompter) Input(prompt, defaultValue string) (string, error) { var result string err := p.ask(&survey.Input{ Message: prompt, Default: defaultValue, }, &result) return result, err } // Password prompts the user to input a single-line string without echoing the input. func (p *Prompter) Password(prompt string) (string, error) { var result string err := p.ask(&survey.Password{ Message: prompt, }, &result) return result, err } // Confirm prompts the user to confirm a yes/no question. func (p *Prompter) Confirm(prompt string, defaultValue bool) (bool, error) { var result bool err := p.ask(&survey.Confirm{ Message: prompt, Default: defaultValue, }, &result) return result, err } func (p *Prompter) ask(q survey.Prompt, response interface{}, opts ...survey.AskOpt) error { opts = append(opts, survey.WithStdio(p.stdin, p.stdout, p.stderr)) err := survey.AskOne(q, response, opts...) if err == nil { return nil } return fmt.Errorf("could not prompt: %w", err) } // latinMatchingFilter returns whether the value matches the input filter. // The strings are compared normalized in case. // The filter's diactritics are kept as-is, but the value's are normalized, // so that a missing diactritic in the filter still returns a result. func latinMatchingFilter(filter, value string, index int) bool { filter = strings.ToLower(filter) value = strings.ToLower(value) // include this option if it matches. return strings.Contains(value, filter) || strings.Contains(text.RemoveDiacritics(value), filter) } go-gh-2.6.0/pkg/prompter/prompter_test.go000066400000000000000000000036121457133626100204210ustar00rootroot00000000000000package prompter import ( "fmt" "log" "os" "testing" "github.com/cli/go-gh/v2/pkg/term" "github.com/stretchr/testify/assert" ) func ExamplePrompter() { term := term.FromEnv() in, ok := term.In().(*os.File) if !ok { log.Fatal("error casting to file") } out, ok := term.Out().(*os.File) if !ok { log.Fatal("error casting to file") } errOut, ok := term.ErrOut().(*os.File) if !ok { log.Fatal("error casting to file") } prompter := New(in, out, errOut) response, err := prompter.Confirm("Shall we play a game", true) if err != nil { log.Fatal(err) } fmt.Println(response) } func TestLatinMatchingFilter(t *testing.T) { tests := []struct { name string filter string value string want bool }{ { name: "exact match no diacritics", filter: "Mikelis", value: "Mikelis", want: true, }, { name: "exact match no diacritics", filter: "Mikelis", value: "Mikelis", want: true, }, { name: "exact match diacritics", filter: "Miķelis", value: "Miķelis", want: true, }, { name: "partial match diacritics", filter: "Miķe", value: "Miķelis", want: true, }, { name: "exact match diacritics in value", filter: "Mikelis", value: "Miķelis", want: true, }, { name: "partial match diacritics in filter", filter: "Miķe", value: "Miķelis", want: true, }, { name: "no match when removing diacritics in filter", filter: "Mielis", value: "Mikelis", want: false, }, { name: "no match when removing diacritics in value", filter: "Mikelis", value: "Mielis", want: false, }, { name: "no match diacritics in filter", filter: "Miķelis", value: "Mikelis", want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, latinMatchingFilter(tt.filter, tt.value, 0), tt.want) }) } } go-gh-2.6.0/pkg/repository/000077500000000000000000000000001457133626100155305ustar00rootroot00000000000000go-gh-2.6.0/pkg/repository/repository.go000066400000000000000000000065371457133626100203110ustar00rootroot00000000000000// Package repository is a set of types and functions for modeling and // interacting with GitHub repositories. package repository import ( "errors" "fmt" "os" "strings" "github.com/cli/go-gh/v2/internal/git" "github.com/cli/go-gh/v2/pkg/auth" "github.com/cli/go-gh/v2/pkg/ssh" ) // Repository holds information representing a GitHub repository. type Repository struct { Host string Name string Owner string } // Parse extracts the repository information from the following // string formats: "OWNER/REPO", "HOST/OWNER/REPO", and a full URL. // If the format does not specify a host, use the config to determine a host. func Parse(s string) (Repository, error) { var r Repository if git.IsURL(s) { u, err := git.ParseURL(s) if err != nil { return r, err } host, owner, name, err := git.RepoInfoFromURL(u) if err != nil { return r, err } r.Host = host r.Name = name r.Owner = owner return r, nil } parts := strings.SplitN(s, "/", 4) for _, p := range parts { if len(p) == 0 { return r, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, s) } } switch len(parts) { case 3: r.Host = parts[0] r.Owner = parts[1] r.Name = parts[2] return r, nil case 2: r.Host, _ = auth.DefaultHost() r.Owner = parts[0] r.Name = parts[1] return r, nil default: return r, 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) { var r Repository if git.IsURL(s) { u, err := git.ParseURL(s) if err != nil { return r, err } host, owner, name, err := git.RepoInfoFromURL(u) if err != nil { return r, err } r.Host = host r.Owner = owner r.Name = name return r, nil } parts := strings.SplitN(s, "/", 4) for _, p := range parts { if len(p) == 0 { return r, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, s) } } switch len(parts) { case 3: r.Host = parts[0] r.Owner = parts[1] r.Name = parts[2] return r, nil case 2: r.Host = host r.Owner = parts[0] r.Name = parts[1] return r, nil default: return r, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, s) } } // Current uses git remotes to determine the GitHub repository // the current directory is tracking. func Current() (Repository, error) { var r Repository override := os.Getenv("GH_REPO") if override != "" { return Parse(override) } remotes, err := git.Remotes() if err != nil { return r, err } if len(remotes) == 0 { return r, errors.New("unable to determine current repository, no git remotes configured for this repository") } translator := ssh.NewTranslator() for _, r := range remotes { if r.FetchURL != nil { r.FetchURL = translator.Translate(r.FetchURL) } if r.PushURL != nil { r.PushURL = translator.Translate(r.PushURL) } } hosts := auth.KnownHosts() filteredRemotes := remotes.FilterByHosts(hosts) if len(filteredRemotes) == 0 { return r, errors.New("unable to determine current repository, none of the git remotes configured for this repository point to a known GitHub host") } rem := filteredRemotes[0] r.Host = rem.Host r.Owner = rem.Owner r.Name = rem.Repo return r, nil } go-gh-2.6.0/pkg/repository/repository_test.go000066400000000000000000000107611457133626100213420ustar00rootroot00000000000000package repository import ( "testing" "github.com/cli/go-gh/v2/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) (*config.Config, error) { return config.ReadFromString(cfgStr), nil } t.Cleanup(func() { config.Read = old }) } go-gh-2.6.0/pkg/ssh/000077500000000000000000000000001457133626100141065ustar00rootroot00000000000000go-gh-2.6.0/pkg/ssh/ssh.go000066400000000000000000000044321457133626100152350ustar00rootroot00000000000000// 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] } } err = sshCmd.Wait() if err != nil || resolvedHost == "" { // handle failures by returning the original hostname unchanged resolvedHost = hostname } if t.cacheMap == nil { t.cacheMap = map[string]string{} } t.cacheMap[strings.ToLower(hostname)] = resolvedHost return resolvedHost, nil } go-gh-2.6.0/pkg/ssh/ssh_test.go000066400000000000000000000071711457133626100162770ustar00rootroot00000000000000package ssh import ( "errors" "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 { if len(args) < 3 || args[2] == "error" { return errors.New("fatal") } if args[2] == "empty.io" { return nil } fmt.Fprintf(os.Stdout, "hostname %s\n", args[2]) 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 }, } tests := []struct { input string result string }{ { input: "ssh://github1.com/owner/repo.git", result: "github1.com", }, { input: "ssh://github2.com/owner/repo.git", result: "github2.com", }, { input: "ssh://empty.io/owner/repo.git", result: "empty.io", }, { input: "ssh://error/owner/repo.git", result: "error", }, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { u, err := url.Parse(tt.input) if err != nil { t.Fatalf("error parsing URL: %v", err) } if res := tr.Translate(u); res.Host != tt.result { t.Errorf("expected github.com, got: %q", res.Host) } if res := tr.Translate(u); res.Host != tt.result { t.Errorf("expected github.com, got: %q (second call)", res.Host) } }) } if countLookPath != 1 { t.Errorf("expected lookPath to happen 1 time; actual: %d", countLookPath) } if countNewCommand != len(tests) { t.Errorf("expected ssh command to shell out %d times; actual: %d", len(tests), countNewCommand) } } go-gh-2.6.0/pkg/tableprinter/000077500000000000000000000000001457133626100160045ustar00rootroot00000000000000go-gh-2.6.0/pkg/tableprinter/table.go000066400000000000000000000147161457133626100174330ustar00rootroot00000000000000// 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" "github.com/cli/go-gh/v2/pkg/text" ) type fieldOption func(*tableField) type TablePrinter interface { AddHeader([]string, ...fieldOption) 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. The truncation function will be called before padding and coloring. // Pass nil to disable truncation for this value. func WithTruncate(fn func(int, string) string) fieldOption { return func(f *tableField) { f.truncateFunc = fn } } // WithPadding overrides the padding function for the field. The function should transform a string argument // into a string that is padded to fit within the given display width. The default behavior is to pad fields // with spaces except for the last field. The padding function will be called after truncation and before coloring. // Pass nil to disable padding for this value. func WithPadding(fn func(int, string) string) fieldOption { return func(f *tableField) { f.paddingFunc = 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. // The color function will be called before truncation and padding. 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 paddingFunc func(int, string) string colorFunc func(string) string } type ttyTablePrinter struct { out io.Writer maxWidth int hasHeaders bool rows [][]tableField } func (t *ttyTablePrinter) AddHeader(columns []string, opts ...fieldOption) { if t.hasHeaders { return } t.hasHeaders = true for _, column := range columns { t.AddField(column, opts...) } t.EndRow() } 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 field.paddingFunc != nil { truncVal = field.paddingFunc(colWidths[col], truncVal) } else if col < numCols-1 { truncVal = text.PadRight(colWidths[col], truncVal) } 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) AddHeader(_ []string, _ ...fieldOption) {} func (t *tsvTablePrinter) AddField(text string, _ ...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-2.6.0/pkg/tableprinter/table_test.go000066400000000000000000000076001457133626100204640ustar00rootroot00000000000000package tableprinter import ( "bytes" "fmt" "log" "os" "strings" "testing" "github.com/MakeNowJust/heredoc" ) 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_ttyTablePrinter_AddHeader(t *testing.T) { buf := bytes.Buffer{} tp := New(&buf, true, 80) tp.AddHeader([]string{"ONE", "TWO", "THREE"}, WithColor(func(s string) string { return fmt.Sprintf("\x1b[4m%s\x1b[m", s) })) // Subsequent calls to AddHeader are ignored. tp.AddHeader([]string{"SHOULD", "NOT", "EXIST"}) tp.AddField("hello") tp.AddField("beautiful") tp.AddField("people") tp.EndRow() err := tp.Render() if err != nil { t.Fatalf("unexpected error: %v", err) } expected := heredoc.Docf(` %[1]s[4mONE %[1]s[m %[1]s[4mTWO %[1]s[m %[1]s[4mTHREE%[1]s[m hello beautiful people `, "\x1b") if buf.String() != expected { t.Errorf("expected: %q, got: %q", expected, buf.String()) } } func Test_ttyTablePrinter_WithPadding(t *testing.T) { buf := bytes.Buffer{} tp := New(&buf, true, 80) // Center the headers. tp.AddHeader([]string{"A", "B", "C"}, WithPadding(func(width int, s string) string { left := (width - len(s)) / 2 return strings.Repeat(" ", left) + s + strings.Repeat(" ", width-left-len(s)) })) tp.AddField("hello") tp.AddField("beautiful") tp.AddField("people") tp.EndRow() err := tp.Render() if err != nil { t.Fatalf("unexpected error: %v", err) } expected := heredoc.Doc(` A B C hello beautiful people `) 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()) } } func Test_tsvTablePrinter_AddHeader(t *testing.T) { buf := bytes.Buffer{} tp := New(&buf, false, 0) // Headers are not output in TSV output. tp.AddHeader([]string{"ONE", "TWO", "THREE"}) tp.AddField("hello") tp.AddField("beautiful") tp.AddField("people") tp.EndRow() tp.AddField("1") tp.AddField("2") tp.AddField("3") tp.EndRow() err := tp.Render() if err != nil { t.Fatalf("unexpected error: %v", err) } expected := "hello\tbeautiful\tpeople\n1\t2\t3\n" if buf.String() != expected { t.Errorf("expected: %q, got: %q", expected, buf.String()) } } go-gh-2.6.0/pkg/template/000077500000000000000000000000001457133626100151245ustar00rootroot00000000000000go-gh-2.6.0/pkg/template/template.go000066400000000000000000000152461457133626100172760ustar00rootroot00000000000000// 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/v2/pkg/tableprinter" "github.com/cli/go-gh/v2/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-2.6.0/pkg/template/template_test.go000066400000000000000000000270301457133626100203270ustar00rootroot00000000000000package template import ( "bytes" "fmt" "io" "log" "os" "strings" "testing" "time" "github.com/MakeNowJust/heredoc" "github.com/cli/go-gh/v2/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-2.6.0/pkg/term/000077500000000000000000000000001457133626100142605ustar00rootroot00000000000000go-gh-2.6.0/pkg/term/console.go000066400000000000000000000003651457133626100162550ustar00rootroot00000000000000//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-2.6.0/pkg/term/console_windows.go000066400000000000000000000006521457133626100200260ustar00rootroot00000000000000//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-2.6.0/pkg/term/env.go000066400000000000000000000113171457133626100154020ustar00rootroot00000000000000// 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 = IsColorForced() || (!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" } // IsColorForced returns true if environment variable CLICOLOR_FORCE is set to force colored terminal output. func IsColorForced() 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-2.6.0/pkg/term/env_test.go000066400000000000000000000054041457133626100164410ustar00rootroot00000000000000// 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-2.6.0/pkg/text/000077500000000000000000000000001457133626100142755ustar00rootroot00000000000000go-gh-2.6.0/pkg/text/text.go000066400000000000000000000060111457133626100156060ustar00rootroot00000000000000// Package text is a set of utility functions for text processing and outputting to the terminal. package text import ( "fmt" "regexp" "strings" "time" "unicode" "github.com/muesli/reflow/ansi" "github.com/muesli/reflow/truncate" "golang.org/x/text/runes" "golang.org/x/text/transform" "golang.org/x/text/unicode/norm" ) 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 } // PadRight returns a copy of the string s that has been padded on the right with whitespace to fit // the maximum display width. func PadRight(maxWidth int, s string) string { if padWidth := maxWidth - DisplayWidth(s); padWidth > 0 { s += strings.Repeat(" ", padWidth) } return s } // 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") } // RemoveDiacritics returns the input value without "diacritics", or accent marks. func RemoveDiacritics(value string) string { // Mn = "Mark, nonspacing" unicode character category removeMnTransfomer := runes.Remove(runes.In(unicode.Mn)) // 1. Decompose the text into characters and diacritical marks // 2. Remove the diacriticals marks // 3. Recompose the text t := transform.Chain(norm.NFD, removeMnTransfomer, norm.NFC) normalized, _, err := transform.String(t, value) if err != nil { return value } return normalized } go-gh-2.6.0/pkg/text/text_test.go000066400000000000000000000202521457133626100166500ustar00rootroot00000000000000package 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 TestPadRight(t *testing.T) { type args struct { max int s string } tests := []struct { name string args args want string }{ { name: "empty", args: args{ s: "", max: 5, }, want: " ", }, { name: "short", args: args{ s: "hello", max: 7, }, want: "hello ", }, { name: "long", args: args{ s: "hello world", max: 5, }, want: "hello world", }, { name: "exact", args: args{ s: "hello world", max: 11, }, want: "hello world", }, { name: "Japanese", args: args{ s: "テストテスト", max: 13, }, want: "テストテスト ", }, { name: "Japanese filled", args: args{ s: "aテスト", max: 9, }, want: "aテスト ", }, { name: "Chinese", args: args{ s: "幫新舉報違章工廠新增編號", max: 26, }, want: "幫新舉報違章工廠新增編號 ", }, { name: "Chinese filled", args: args{ s: "a幫新舉報違章工廠新增編號", max: 26, }, want: "a幫新舉報違章工廠新增編號 ", }, { name: "Korean", args: args{ s: "프로젝트 내의", max: 15, }, want: "프로젝트 내의 ", }, { name: "Korean filled", args: args{ s: "a프로젝트 내의", max: 15, }, want: "a프로젝트 내의 ", }, { name: "Emoji", args: args{ s: "💡💡💡💡", max: 10, }, want: "💡💡💡💡 ", }, { name: "Accented characters", args: args{ s: "é́́é́́é́́é́́é́́", max: 7, }, want: "é́́é́́é́́é́́é́́ ", }, { name: "Red accented characters", args: args{ s: "\x1b[0;31mé́́é́́é́́é́́é́́\x1b[0m", max: 7, }, want: "\x1b[0;31mé́́é́́é́́é́́é́́\x1b[0m ", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := PadRight(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) }) } } func TestRemoveDiacritics(t *testing.T) { tests := [][]string{ // no diacritics {"e", "e"}, {"و", "و"}, {"И", "И"}, {"ж", "ж"}, {"私", "私"}, {"万", "万"}, // diacritics test sets {"à", "a"}, {"é", "e"}, {"è", "e"}, {"ô", "o"}, {"ᾳ", "α"}, {"εͅ", "ε"}, {"ῃ", "η"}, {"ιͅ", "ι"}, {"ؤ", "و"}, {"ā", "a"}, {"č", "c"}, {"ģ", "g"}, {"ķ", "k"}, {"ņ", "n"}, {"š", "s"}, {"ž", "z"}, {"ŵ", "w"}, {"ŷ", "y"}, {"ä", "a"}, {"ÿ", "y"}, {"á", "a"}, {"ẁ", "w"}, {"ỳ", "y"}, {"ō", "o"}, // full words {"Miķelis", "Mikelis"}, {"François", "Francois"}, {"žluťoučký", "zlutoucky"}, {"învățătorița", "invatatorita"}, {"Kękę przy łóżku", "Keke przy łozku"}, } for _, tt := range tests { t.Run(RemoveDiacritics(tt[0]), func(t *testing.T) { assert.Equal(t, tt[1], RemoveDiacritics(tt[0])) }) } }