diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..1a850c9b3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: +- package-ecosystem: gomod + directory: "/" + schedule: + interval: "daily" + ignore: + - dependency-name: "*" + update-types: + - version-update:semver-minor + - version-update:semver-major +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9e3848c26..d46c0bcf3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,7 +2,11 @@ name: Code Scanning on: push: + branches: [trunk] pull_request: + branches: [trunk] + paths-ignore: + - '**/*.md' schedule: - cron: "0 0 * * 0" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 226050ea9..f12d8f0d0 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -17,6 +17,15 @@ jobs: - name: Check out code uses: actions/checkout@v2 + - name: Cache Go modules + uses: actions/cache@v2 + with: + path: ~/go + key: ${{ runner.os }}-build-${{ hashFiles('go.mod') }} + restore-keys: | + ${{ runner.os }}-build- + ${{ runner.os }}- + - name: Download dependencies run: go mod download diff --git a/.github/workflows/issueauto.yml b/.github/workflows/issueauto.yml new file mode 100644 index 000000000..a366d6ed8 --- /dev/null +++ b/.github/workflows/issueauto.yml @@ -0,0 +1,19 @@ +name: Issue Automation +on: + issues: + types: [opened] +jobs: + issue-auto: + runs-on: ubuntu-latest + steps: + - name: label incoming issue + env: + GH_REPO: ${{ github.repository }} + GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }} + ISSUENUM: ${{ github.event.issue.number }} + ISSUEAUTHOR: ${{ github.event.issue.user.login }} + run: | + if ! gh api orgs/cli/public_members/$ISSUEAUTHOR --silent 2>/dev/null + then + gh issue edit $ISSUENUM --add-label "needs-triage" + fi \ No newline at end of file diff --git a/.github/workflows/prauto.yml b/.github/workflows/prauto.yml index 20a88b31e..047fb52ea 100644 --- a/.github/workflows/prauto.yml +++ b/.github/workflows/prauto.yml @@ -15,6 +15,7 @@ jobs: PRNUM: ${{ github.event.pull_request.number }} PRHEAD: ${{ github.event.pull_request.head.label }} PRAUTHOR: ${{ github.event.pull_request.user.login }} + PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }} if: "!github.event.pull_request.draft" run: | commentPR () { @@ -42,8 +43,12 @@ jobs: ' -f colID="$(colID "Needs review")" -f prID="$PRID" } - if gh api orgs/cli/public_members/$PRAUTHOR --silent 2>/dev/null + if [ "$PR_AUTHOR_TYPE" = "Bot" ] || gh api orgs/cli/public_members/$PRAUTHOR --silent 2>/dev/null then + if [ "$PR_AUTHOR_TYPE" != "Bot" ] + then + gh pr edit $PRNUM --add-assignee $PRAUTHOR + fi if ! errtext="$(addToBoard 2>&1)" then cat <<<"$errtext" >&2 @@ -55,6 +60,8 @@ jobs: exit 0 fi + gh pr edit $PRNUM --add-label "external" + if [ "$PRHEAD" = "cli:trunk" ] then closePR diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 63a3891e6..5d9d6a509 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -16,10 +16,17 @@ jobs: with: go-version: 1.16 - name: Generate changelog + id: changelog run: | - echo "GORELEASER_CURRENT_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV - git fetch --unshallow - script/changelog | tee CHANGELOG.md + echo "::set-output name=tag-name::${GITHUB_REF#refs/tags/}" + gh api repos/$GITHUB_REPOSITORY/releases/generate-notes \ + -f tag_name="${GITHUB_REF#refs/tags/}" \ + -f target_commitish=trunk \ + -q .body > CHANGELOG.md + env: + GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Install osslsigncode + run: sudo apt-get install -y osslsigncode - name: Run GoReleaser uses: goreleaser/goreleaser-action@v2 with: @@ -27,13 +34,16 @@ jobs: args: release --release-notes=CHANGELOG.md env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + GORELEASER_CURRENT_TAG: ${{steps.changelog.outputs.tag-name}} + GITHUB_CERT_PASSWORD: ${{secrets.GITHUB_CERT_PASSWORD}} + DESKTOP_CERT_TOKEN: ${{secrets.DESKTOP_CERT_TOKEN}} - name: Checkout documentation site uses: actions/checkout@v2 with: repository: github/cli.github.com path: site fetch-depth: 0 - token: ${{secrets.SITE_GITHUB_TOKEN}} + ssh-key: ${{secrets.SITE_SSH_KEY}} - name: Update site man pages env: GIT_COMMITTER_NAME: cli automation @@ -55,7 +65,6 @@ jobs: api-write --silent projects/columns/cards/$card/moves -f position=top -F column_id=$DONE_COLUMN done echo "moved ${#cards[@]} cards to the Done column" - - name: Install packaging dependencies run: sudo apt-get install -y rpm reprepro - name: Set up GPG diff --git a/.goreleaser.yml b/.goreleaser.yml index 4c5f62a06..01c727d93 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -8,7 +8,8 @@ release: before: hooks: - go mod tidy - - make manpages + - make manpages GH_VERSION={{.Version}} + - ./script/prepare-windows-cert.sh '{{ if index .Env "GITHUB_CERT_PASSWORD" }}{{ .Env.GITHUB_CERT_PASSWORD}}{{ end }}' '{{ if index .Env "DESKTOP_CERT_TOKEN" }}{{ .Env.DESKTOP_CERT_TOKEN}}{{ end }}' builds: - <<: &build_defaults @@ -32,6 +33,9 @@ builds: id: windows goos: [windows] goarch: [386, amd64] + hooks: + post: + - ./script/sign-windows-executable.sh '{{ .Path }}' archives: - id: nix diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 000000000..4b9029313 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,5 @@ +* @cli/code-reviewers + +pkg/cmd/codespace/ @cli/codespaces +pkg/liveshare/ @cli/codespaces +internal/codespaces/ @cli/codespaces diff --git a/README.md b/README.md index 71630a5cc..e503c88c9 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ If anything feels off, or if you feel that some functionality is missing, please ### macOS -`gh` is available via [Homebrew][], [MacPorts][], [Conda][], and as a downloadable binary from the [releases page][]. +`gh` is available via [Homebrew][], [MacPorts][], [Conda][], [Spack][], and as a downloadable binary from the [releases page][]. #### Homebrew @@ -41,21 +41,27 @@ If anything feels off, or if you feel that some functionality is missing, please Additional Conda installation options available on the [gh-feedstock page](https://github.com/conda-forge/gh-feedstock#installing-gh). +#### Spack + +| Install: | Upgrade: | +| ------------------ | ---------------------------------------- | +| `spack install gh` | `spack uninstall gh && spack install gh` | + ### Linux & BSD -`gh` is available via [Homebrew](#homebrew), [Conda](#Conda), and as downloadable binaries from the [releases page][]. +`gh` is available via [Homebrew](#homebrew), [Conda](#conda), [Spack](#spack), and as downloadable binaries from the [releases page][]. For instructions on specific distributions and package managers, see [Linux & BSD installation](./docs/install_linux.md). ### Windows -`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#Conda), and as downloadable MSI. +`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#conda), and as downloadable MSI. #### WinGet | Install: | Upgrade: | | ------------------- | --------------------| -| `winget install gh` | `winget upgrade gh` | +| `winget install --id GitHub.cli` | `winget upgrade --id GitHub.cli` | #### scoop @@ -99,6 +105,7 @@ tool. Check out our [more detailed explanation][gh-vs-hub] to learn more. [scoop]: https://scoop.sh [Chocolatey]: https://chocolatey.org [Conda]: https://docs.conda.io/en/latest/ +[Spack]: https://spack.io [releases page]: https://github.com/cli/cli/releases/latest [hub]: https://github.com/github/hub [contributing]: ./.github/CONTRIBUTING.md diff --git a/api/client.go b/api/client.go index a5741a42e..bf6827d34 100644 --- a/api/client.go +++ b/api/client.go @@ -12,8 +12,8 @@ import ( "strings" "github.com/cli/cli/v2/internal/ghinstance" + graphql "github.com/cli/shurcooL-graphql" "github.com/henvic/httpretty" - "github.com/shurcooL/graphql" ) // ClientOption represents an argument to NewClient @@ -98,6 +98,22 @@ func ReplaceTripper(tr http.RoundTripper) ClientOption { } } +// ExtractHeader extracts a named header from any response received by this client and, if non-blank, saves +// it to dest. +func ExtractHeader(name string, dest *string) ClientOption { + return func(tr http.RoundTripper) http.RoundTripper { + return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { + res, err := tr.RoundTrip(req) + if err == nil { + if value := res.Header.Get(name); value != "" { + *dest = value + } + } + return res, err + }} + } +} + type funcTripper struct { roundTrip func(*http.Request) (*http.Response, error) } @@ -124,7 +140,18 @@ type graphQLResponse struct { type GraphQLError struct { Type string Message string - // Path []interface // mixed strings and numbers + Path []interface{} // mixed strings and numbers +} + +func (ge GraphQLError) PathString() string { + var res strings.Builder + for i, v := range ge.Path { + if i > 0 { + res.WriteRune('.') + } + fmt.Fprintf(&res, "%v", v) + } + return res.String() } // GraphQLErrorResponse contains errors returned in a GraphQL response @@ -135,18 +162,41 @@ type GraphQLErrorResponse struct { func (gr GraphQLErrorResponse) Error() string { errorMessages := make([]string, 0, len(gr.Errors)) for _, e := range gr.Errors { - errorMessages = append(errorMessages, e.Message) + msg := e.Message + if p := e.PathString(); p != "" { + msg = fmt.Sprintf("%s (%s)", msg, p) + } + errorMessages = append(errorMessages, msg) } - return fmt.Sprintf("GraphQL error: %s", strings.Join(errorMessages, "\n")) + return fmt.Sprintf("GraphQL: %s", strings.Join(errorMessages, ", ")) +} + +// Match checks if this error is only about a specific type on a specific path. If the path argument ends +// with a ".", it will match all its subpaths as well. +func (gr GraphQLErrorResponse) Match(expectType, expectPath string) bool { + for _, e := range gr.Errors { + if e.Type != expectType || !matchPath(e.PathString(), expectPath) { + return false + } + } + return true +} + +func matchPath(p, expect string) bool { + if strings.HasSuffix(expect, ".") { + return strings.HasPrefix(p, expect) || p == strings.TrimSuffix(expect, ".") + } + return p == expect } // HTTPError is an error returned by a failed API call type HTTPError struct { - StatusCode int - RequestURL *url.URL - Message string - OAuthScopes string - Errors []HTTPErrorItem + StatusCode int + RequestURL *url.URL + Message string + Errors []HTTPErrorItem + + scopesSuggestion string } type HTTPErrorItem struct { @@ -165,7 +215,63 @@ func (err HTTPError) Error() string { return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL) } -// GraphQL performs a GraphQL request and parses the response +func (err HTTPError) ScopesSuggestion() string { + return err.scopesSuggestion +} + +// ScopesSuggestion is an error messaging utility that prints the suggestion to request additional OAuth +// scopes in case a server response indicates that there are missing scopes. +func ScopesSuggestion(resp *http.Response) string { + if resp.StatusCode < 400 || resp.StatusCode > 499 || resp.StatusCode == 422 { + return "" + } + + endpointNeedsScopes := resp.Header.Get("X-Accepted-Oauth-Scopes") + tokenHasScopes := resp.Header.Get("X-Oauth-Scopes") + if tokenHasScopes == "" { + return "" + } + + gotScopes := map[string]struct{}{} + for _, s := range strings.Split(tokenHasScopes, ",") { + s = strings.TrimSpace(s) + gotScopes[s] = struct{}{} + if strings.HasPrefix(s, "admin:") { + gotScopes["read:"+strings.TrimPrefix(s, "admin:")] = struct{}{} + gotScopes["write:"+strings.TrimPrefix(s, "admin:")] = struct{}{} + } else if strings.HasPrefix(s, "write:") { + gotScopes["read:"+strings.TrimPrefix(s, "write:")] = struct{}{} + } + } + + for _, s := range strings.Split(endpointNeedsScopes, ",") { + s = strings.TrimSpace(s) + if _, gotScope := gotScopes[s]; s == "" || gotScope { + continue + } + return fmt.Sprintf( + "This API operation needs the %[1]q scope. To request it, run: gh auth refresh -h %[2]s -s %[1]s", + s, + ghinstance.NormalizeHostname(resp.Request.URL.Hostname()), + ) + } + + return "" +} + +// EndpointNeedsScopes adds additional OAuth scopes to an HTTP response as if they were returned from the +// server endpoint. This improves HTTP 4xx error messaging for endpoints that don't explicitly list the +// OAuth scopes they need. +func EndpointNeedsScopes(resp *http.Response, s string) *http.Response { + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + oldScopes := resp.Header.Get("X-Accepted-Oauth-Scopes") + resp.Header.Set("X-Accepted-Oauth-Scopes", fmt.Sprintf("%s, %s", oldScopes, s)) + } + return resp +} + +// GraphQL performs a GraphQL request and parses the response. If there are errors in the response, +// *GraphQLErrorResponse will be returned, but the data will also be parsed into the receiver. func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error { reqBody, err := json.Marshal(map[string]interface{}{"query": query, "variables": variables}) if err != nil { @@ -261,9 +367,9 @@ func handleResponse(resp *http.Response, data interface{}) error { func HandleHTTPError(resp *http.Response) error { httpError := HTTPError{ - StatusCode: resp.StatusCode, - RequestURL: resp.Request.URL, - OAuthScopes: resp.Header.Get("X-Oauth-Scopes"), + StatusCode: resp.StatusCode, + RequestURL: resp.Request.URL, + scopesSuggestion: ScopesSuggestion(resp), } if !jsonTypeRE.MatchString(resp.Header.Get("Content-Type")) { diff --git a/api/client_test.go b/api/client_test.go index 50665fddb..ccf911f94 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -50,15 +50,23 @@ func TestGraphQLError(t *testing.T) { httpmock.GraphQL(""), httpmock.StringResponse(` { "errors": [ - {"message":"OH NO"}, - {"message":"this is fine"} + { + "type": "NOT_FOUND", + "message": "OH NO", + "path": ["repository", "issue"] + }, + { + "type": "ACTUALLY_ITS_FINE", + "message": "this is fine", + "path": ["repository", "issues", 0, "comments"] + } ] } `), ) err := client.GraphQL("github.com", "", nil, &response) - if err == nil || err.Error() != "GraphQL error: OH NO\nthis is fine" { + if err == nil || err.Error() != "GraphQL: OH NO (repository.issue), this is fine (repository.issues.0.comments)" { t.Fatalf("got %q", err.Error()) } } @@ -146,3 +154,72 @@ func TestHandleHTTPError_GraphQL502(t *testing.T) { t.Errorf("got error: %v", err) } } + +func TestHTTPError_ScopesSuggestion(t *testing.T) { + makeResponse := func(s int, u, haveScopes, needScopes string) *http.Response { + req, err := http.NewRequest("GET", u, nil) + if err != nil { + t.Fatal(err) + } + return &http.Response{ + Request: req, + StatusCode: s, + Body: ioutil.NopCloser(bytes.NewBufferString(`{}`)), + Header: map[string][]string{ + "Content-Type": {"application/json"}, + "X-Oauth-Scopes": {haveScopes}, + "X-Accepted-Oauth-Scopes": {needScopes}, + }, + } + } + + tests := []struct { + name string + resp *http.Response + want string + }{ + { + name: "has necessary scopes", + resp: makeResponse(404, "https://api.github.com/gists", "repo, gist, read:org", "gist"), + want: ``, + }, + { + name: "normalizes scopes", + resp: makeResponse(404, "https://api.github.com/orgs/ORG/discussions", "admin:org, write:discussion", "read:org, read:discussion"), + want: ``, + }, + { + name: "no scopes on endpoint", + resp: makeResponse(404, "https://api.github.com/user", "repo", ""), + want: ``, + }, + { + name: "missing a scope", + resp: makeResponse(404, "https://api.github.com/gists", "repo, read:org", "gist, delete_repo"), + want: `This API operation needs the "gist" scope. To request it, run: gh auth refresh -h github.com -s gist`, + }, + { + name: "server error", + resp: makeResponse(500, "https://api.github.com/gists", "repo", "gist"), + want: ``, + }, + { + name: "no scopes on token", + resp: makeResponse(404, "https://api.github.com/gists", "", "gist, delete_repo"), + want: ``, + }, + { + name: "http code is 422", + resp: makeResponse(422, "https://api.github.com/gists", "", "gist"), + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + httpError := HandleHTTPError(tt.resp) + if got := httpError.(HTTPError).ScopesSuggestion(); got != tt.want { + t.Errorf("HTTPError.ScopesSuggestion() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/api/export_pr.go b/api/export_pr.go index 29a5c4a63..18bce025b 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -5,7 +5,7 @@ import ( "strings" ) -func (issue *Issue) ExportData(fields []string) *map[string]interface{} { +func (issue *Issue) ExportData(fields []string) map[string]interface{} { v := reflect.ValueOf(issue).Elem() data := map[string]interface{}{} @@ -25,10 +25,10 @@ func (issue *Issue) ExportData(fields []string) *map[string]interface{} { } } - return &data + return data } -func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} { +func (pr *PullRequest) ExportData(fields []string) map[string]interface{} { v := reflect.ValueOf(pr).Elem() data := map[string]interface{}{} @@ -102,7 +102,7 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} { } } - return &data + return data } func fieldByName(v reflect.Value, field string) reflect.Value { diff --git a/api/export_repo.go b/api/export_repo.go index 8d4e669ad..a07246ab9 100644 --- a/api/export_repo.go +++ b/api/export_repo.go @@ -4,7 +4,7 @@ import ( "reflect" ) -func (repo *Repository) ExportData(fields []string) *map[string]interface{} { +func (repo *Repository) ExportData(fields []string) map[string]interface{} { v := reflect.ValueOf(repo).Elem() data := map[string]interface{}{} @@ -38,7 +38,7 @@ func (repo *Repository) ExportData(fields []string) *map[string]interface{} { } } - return &data + return data } func miniRepoExport(r *Repository) map[string]interface{} { diff --git a/api/queries_comments.go b/api/queries_comments.go index 999c39033..85985dd12 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -4,8 +4,8 @@ import ( "context" "time" + graphql "github.com/cli/shurcooL-graphql" "github.com/shurcooL/githubv4" - "github.com/shurcooL/graphql" ) type Comments struct { diff --git a/api/queries_issue.go b/api/queries_issue.go index d035e6ad8..4146bfeaa 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -1,12 +1,10 @@ package api import ( - "context" "fmt" "time" "github.com/cli/cli/v2/internal/ghrepo" - "github.com/shurcooL/githubv4" ) type IssuesPayload struct { @@ -16,11 +14,13 @@ type IssuesPayload struct { } type IssuesAndTotalCount struct { - Issues []Issue - TotalCount int + Issues []Issue + TotalCount int + SearchCapped bool } type Issue struct { + Typename string `json:"__typename"` ID string Number int Title string @@ -40,6 +40,10 @@ type Issue struct { ReactionGroups ReactionGroups } +func (i Issue) IsPullRequest() bool { + return i.Typename == "PullRequest" +} + type Assignees struct { Nodes []GitHubUser TotalCount int @@ -67,17 +71,19 @@ func (l Labels) Names() []string { } type ProjectCards struct { - Nodes []struct { - Project struct { - Name string `json:"name"` - } `json:"project"` - Column struct { - Name string `json:"name"` - } `json:"column"` - } + Nodes []*ProjectInfo TotalCount int } +type ProjectInfo struct { + Project struct { + Name string `json:"name"` + } `json:"project"` + Column struct { + Name string `json:"name"` + } `json:"column"` +} + func (p ProjectCards) ProjectNames() []string { names := make([]string, len(p.Nodes)) for i, c := range p.Nodes { @@ -229,194 +235,6 @@ func IssueStatus(client *Client, repo ghrepo.Interface, options IssueStatusOptio return &payload, nil } -func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, error) { - type response struct { - Repository struct { - Issue Issue - HasIssuesEnabled bool - } - } - - query := ` - query IssueByNumber($owner: String!, $repo: String!, $issue_number: Int!) { - repository(owner: $owner, name: $repo) { - hasIssuesEnabled - issue(number: $issue_number) { - id - title - state - body - author { - login - } - comments(last: 1) { - nodes { - author { - login - } - authorAssociation - body - createdAt - includesCreatedEdit - isMinimized - minimizedReason - reactionGroups { - content - users { - totalCount - } - } - } - totalCount - } - number - url - createdAt - assignees(first: 100) { - nodes { - id - name - login - } - totalCount - } - labels(first: 100) { - nodes { - id - name - description - color - } - totalCount - } - projectCards(first: 100) { - nodes { - project { - name - } - column { - name - } - } - totalCount - } - milestone { - number - title - description - dueOn - } - reactionGroups { - content - users { - totalCount - } - } - } - } - }` - - variables := map[string]interface{}{ - "owner": repo.RepoOwner(), - "repo": repo.RepoName(), - "issue_number": number, - } - - var resp response - err := client.GraphQL(repo.RepoHost(), query, variables, &resp) - if err != nil { - return nil, err - } - - if !resp.Repository.HasIssuesEnabled { - - return nil, &IssuesDisabledError{fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))} - } - - return &resp.Repository.Issue, nil -} - -func IssueClose(client *Client, repo ghrepo.Interface, issue Issue) error { - var mutation struct { - CloseIssue struct { - Issue struct { - ID githubv4.ID - } - } `graphql:"closeIssue(input: $input)"` - } - - variables := map[string]interface{}{ - "input": githubv4.CloseIssueInput{ - IssueID: issue.ID, - }, - } - - gql := graphQLClient(client.http, repo.RepoHost()) - err := gql.MutateNamed(context.Background(), "IssueClose", &mutation, variables) - - if err != nil { - return err - } - - return nil -} - -func IssueReopen(client *Client, repo ghrepo.Interface, issue Issue) error { - var mutation struct { - ReopenIssue struct { - Issue struct { - ID githubv4.ID - } - } `graphql:"reopenIssue(input: $input)"` - } - - variables := map[string]interface{}{ - "input": githubv4.ReopenIssueInput{ - IssueID: issue.ID, - }, - } - - gql := graphQLClient(client.http, repo.RepoHost()) - err := gql.MutateNamed(context.Background(), "IssueReopen", &mutation, variables) - - return err -} - -func IssueDelete(client *Client, repo ghrepo.Interface, issue Issue) error { - var mutation struct { - DeleteIssue struct { - Repository struct { - ID githubv4.ID - } - } `graphql:"deleteIssue(input: $input)"` - } - - variables := map[string]interface{}{ - "input": githubv4.DeleteIssueInput{ - IssueID: issue.ID, - }, - } - - gql := graphQLClient(client.http, repo.RepoHost()) - err := gql.MutateNamed(context.Background(), "IssueDelete", &mutation, variables) - - return err -} - -func IssueUpdate(client *Client, repo ghrepo.Interface, params githubv4.UpdateIssueInput) error { - var mutation struct { - UpdateIssue struct { - Issue struct { - ID string - } - } `graphql:"updateIssue(input: $input)"` - } - variables := map[string]interface{}{"input": params} - gql := graphQLClient(client.http, repo.RepoHost()) - err := gql.MutateNamed(context.Background(), "IssueUpdate", &mutation, variables) - return err -} - func (i Issue) Link() string { return i.URL } diff --git a/api/queries_pr.go b/api/queries_pr.go index 1e060b1da..300846c23 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -2,9 +2,7 @@ package api import ( "context" - "errors" "fmt" - "io" "net/http" "strings" "time" @@ -26,6 +24,7 @@ type PullRequestsPayload struct { type PullRequestAndTotalCount struct { TotalCount int PullRequests []PullRequest + SearchCapped bool } type PullRequest struct { @@ -266,30 +265,6 @@ func (pr *PullRequest) DisplayableReviews() PullRequestReviews { return PullRequestReviews{Nodes: published, TotalCount: len(published)} } -func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.ReadCloser, error) { - url := fmt.Sprintf("%srepos/%s/pulls/%d", - ghinstance.RESTPrefix(baseRepo.RepoHost()), ghrepo.FullName(baseRepo), prNumber) - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return nil, err - } - - req.Header.Set("Accept", "application/vnd.github.v3.diff; charset=utf-8") - - resp, err := c.http.Do(req) - if err != nil { - return nil, err - } - - if resp.StatusCode == 404 { - return nil, errors.New("pull request not found") - } else if resp.StatusCode != 200 { - return nil, HandleHTTPError(resp) - } - - return resp.Body, nil -} - type pullRequestFeature struct { HasReviewDecision bool HasStatusCheckRollup bool @@ -646,20 +621,6 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter return pr, nil } -func UpdatePullRequest(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestInput) error { - var mutation struct { - UpdatePullRequest struct { - PullRequest struct { - ID string - } - } `graphql:"updatePullRequest(input: $input)"` - } - variables := map[string]interface{}{"input": params} - gql := graphQLClient(client.http, repo.RepoHost()) - err := gql.MutateNamed(context.Background(), "PullRequestUpdate", &mutation, variables) - return err -} - func UpdatePullRequestReviews(client *Client, repo ghrepo.Interface, params githubv4.RequestReviewsInput) error { var mutation struct { RequestReviews struct { @@ -685,7 +646,7 @@ func isBlank(v interface{}) bool { } } -func PullRequestClose(client *Client, repo ghrepo.Interface, pr *PullRequest) error { +func PullRequestClose(httpClient *http.Client, repo ghrepo.Interface, prID string) error { var mutation struct { ClosePullRequest struct { PullRequest struct { @@ -696,17 +657,15 @@ func PullRequestClose(client *Client, repo ghrepo.Interface, pr *PullRequest) er variables := map[string]interface{}{ "input": githubv4.ClosePullRequestInput{ - PullRequestID: pr.ID, + PullRequestID: prID, }, } - gql := graphQLClient(client.http, repo.RepoHost()) - err := gql.MutateNamed(context.Background(), "PullRequestClose", &mutation, variables) - - return err + gql := graphQLClient(httpClient, repo.RepoHost()) + return gql.MutateNamed(context.Background(), "PullRequestClose", &mutation, variables) } -func PullRequestReopen(client *Client, repo ghrepo.Interface, pr *PullRequest) error { +func PullRequestReopen(httpClient *http.Client, repo ghrepo.Interface, prID string) error { var mutation struct { ReopenPullRequest struct { PullRequest struct { @@ -717,14 +676,12 @@ func PullRequestReopen(client *Client, repo ghrepo.Interface, pr *PullRequest) e variables := map[string]interface{}{ "input": githubv4.ReopenPullRequestInput{ - PullRequestID: pr.ID, + PullRequestID: prID, }, } - gql := graphQLClient(client.http, repo.RepoHost()) - err := gql.MutateNamed(context.Background(), "PullRequestReopen", &mutation, variables) - - return err + gql := graphQLClient(httpClient, repo.RepoHost()) + return gql.MutateNamed(context.Background(), "PullRequestReopen", &mutation, variables) } func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) error { diff --git a/api/queries_repo.go b/api/queries_repo.go index b1e41370d..796c2ed45 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -230,6 +230,36 @@ func (r Repository) ViewerCanTriage() bool { } } +func FetchRepository(client *Client, repo ghrepo.Interface, fields []string) (*Repository, error) { + query := fmt.Sprintf(`query RepositoryInfo($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) {%s} + }`, RepositoryGraphQL(fields)) + + variables := map[string]interface{}{ + "owner": repo.RepoOwner(), + "name": repo.RepoName(), + } + + var result struct { + Repository *Repository + } + if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil { + return nil, err + } + // The GraphQL API should have returned an error in case of a missing repository, but this isn't + // guaranteed to happen when an authentication token with insufficient permissions is being used. + if result.Repository == nil { + return nil, GraphQLErrorResponse{ + Errors: []GraphQLError{{ + Type: "NOT_FOUND", + Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()), + }}, + } + } + + return InitRepoHostname(result.Repository, repo.RepoHost()), nil +} + func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { query := ` fragment repo on Repository { @@ -261,16 +291,24 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { "name": repo.RepoName(), } - result := struct { - Repository Repository - }{} - err := client.GraphQL(repo.RepoHost(), query, variables, &result) - - if err != nil { + var result struct { + Repository *Repository + } + if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil { return nil, err } + // The GraphQL API should have returned an error in case of a missing repository, but this isn't + // guaranteed to happen when an authentication token with insufficient permissions is being used. + if result.Repository == nil { + return nil, GraphQLErrorResponse{ + Errors: []GraphQLError{{ + Type: "NOT_FOUND", + Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()), + }}, + } + } - return InitRepoHostname(&result.Repository, repo.RepoHost()), nil + return InitRepoHostname(result.Repository, repo.RepoHost()), nil } func RepoDefaultBranch(client *Client, repo ghrepo.Interface) (string, error) { @@ -486,6 +524,26 @@ func ForkRepo(client *Client, repo ghrepo.Interface, org string) (*Repository, e }, nil } +func LastCommit(client *Client, repo ghrepo.Interface) (*Commit, error) { + var responseData struct { + Repository struct { + DefaultBranchRef struct { + Target struct { + Commit `graphql:"... on Commit"` + } + } + } `graphql:"repository(owner: $owner, name: $repo)"` + } + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), "repo": githubv4.String(repo.RepoName()), + } + gql := graphQLClient(client.http, repo.RepoHost()) + if err := gql.QueryNamed(context.Background(), "LastCommit", &responseData, variables); err != nil { + return nil, err + } + return &responseData.Repository.DefaultBranchRef.Target.Commit, nil +} + // RepoFindForks finds forks of the repo that are affiliated with the viewer func RepoFindForks(client *Client, repo ghrepo.Interface, limit int) ([]*Repository, error) { result := struct { @@ -703,7 +761,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput go func() { teams, err := OrganizationTeams(client, repo) // TODO: better detection of non-org repos - if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") { + if err != nil && !strings.Contains(err.Error(), "Could not resolve to an Organization") { errc <- fmt.Errorf("error fetching organization teams: %w", err) return } @@ -914,7 +972,7 @@ func RepoAndOrgProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, e orgProjects, err := OrganizationProjects(client, repo) // TODO: better detection of non-org repos - if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") { + if err != nil && !strings.Contains(err.Error(), "Could not resolve to an Organization") { return projects, fmt.Errorf("error fetching organization projects: %w", err) } projects = append(projects, orgProjects...) @@ -925,6 +983,15 @@ func RepoAndOrgProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, e type RepoAssignee struct { ID string Login string + Name string +} + +// DisplayName returns a formatted string that uses Login and Name to be displayed e.g. 'Login (Name)' or 'Login' +func (ra RepoAssignee) DisplayName() string { + if ra.Name != "" { + return fmt.Sprintf("%s (%s)", ra.Login, ra.Name) + } + return ra.Login } // RepoAssignableUsers fetches all the assignable users for a repository diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 5fadf7cfc..5091e656f 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -10,6 +10,27 @@ import ( "github.com/cli/cli/v2/pkg/httpmock" ) +func TestGitHubRepo_notFound(t *testing.T) { + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + + httpReg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(`{ "data": { "repository": null } }`)) + + client := NewClient(ReplaceTripper(httpReg)) + repo, err := GitHubRepo(client, ghrepo.New("OWNER", "REPO")) + if err == nil { + t.Fatal("GitHubRepo did not return an error") + } + if wants := "GraphQL: Could not resolve to a Repository with the name 'OWNER/REPO'."; err.Error() != wants { + t.Errorf("GitHubRepo error: want %q, got %q", wants, err.Error()) + } + if repo != nil { + t.Errorf("GitHubRepo: expected nil repo, got %v", repo) + } +} + func Test_RepoMetadata(t *testing.T) { http := &httpmock.Registry{} client := NewClient(ReplaceTripper(http)) @@ -341,3 +362,28 @@ func Test_RepoMilestones(t *testing.T) { } } } + +func TestDisplayName(t *testing.T) { + tests := []struct { + name string + assignee RepoAssignee + want string + }{ + { + name: "assignee with name", + assignee: RepoAssignee{"123", "octocat123", "Octavious Cath"}, + want: "octocat123 (Octavious Cath)", + }, + { + name: "assignee without name", + assignee: RepoAssignee{"123", "octocat123", ""}, + want: "octocat123", + }, + } + for _, tt := range tests { + actual := tt.assignee.DisplayName() + if actual != tt.want { + t.Errorf("display name was %s wanted %s", actual, tt.want) + } + } +} diff --git a/api/query_builder.go b/api/query_builder.go index c9ab62d13..88f8bfa4b 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -35,6 +35,22 @@ var issueComments = shortenQuery(` } `) +var issueCommentLast = shortenQuery(` + comments(last: 1) { + nodes { + author{login}, + authorAssociation, + body, + createdAt, + includesCreatedEdit, + isMinimized, + minimizedReason, + reactionGroups{content,users{totalCount}} + }, + totalCount + } +`) + var prReviewRequests = shortenQuery(` reviewRequests(first: 100) { nodes { @@ -62,6 +78,7 @@ var prReviews = shortenQuery(` reactionGroups{content,users{totalCount}} } pageInfo{hasNextPage,endCursor} + totalCount } `) @@ -176,6 +193,8 @@ var PullRequestFields = append(IssueFields, "statusCheckRollup", ) +// PullRequestGraphQL constructs a GraphQL query fragment for a set of pull request fields. Since GitHub +// pull requests are also technically issues, this function can be used to query issues as well. func PullRequestGraphQL(fields []string) string { var q []string for _, field := range fields { @@ -204,6 +223,8 @@ func PullRequestGraphQL(fields []string) string { q = append(q, `potentialMergeCommit{oid}`) case "comments": q = append(q, issueComments) + case "lastComment": // pseudo-field + q = append(q, issueCommentLast) case "reviewRequests": q = append(q, prReviewRequests) case "reviews": diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 22c4c95f9..ec9b582af 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -58,13 +58,7 @@ func run(args []string) error { } if *manPage { - header := &docs.GenManHeader{ - Title: "gh", - Section: "1", - Source: "", - Manual: "", - } - if err := docs.GenManTree(rootCmd, header, *dir); err != nil { + if err := docs.GenManTree(rootCmd, *dir); err != nil { return err } } diff --git a/cmd/gen-docs/main_test.go b/cmd/gen-docs/main_test.go index 5c69ff6b2..129b3218f 100644 --- a/cmd/gen-docs/main_test.go +++ b/cmd/gen-docs/main_test.go @@ -18,7 +18,7 @@ func Test_run(t *testing.T) { if err != nil { t.Fatalf("error reading `gh-issue-create.1`: %v", err) } - if !strings.Contains(string(manPage), `\fBgh issue create`) { + if !strings.Contains(string(manPage), `\fB\fCgh issue create`) { t.Fatal("man page corrupted") } diff --git a/cmd/gh/main.go b/cmd/gh/main.go index 98dcc4034..10cd94c2e 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -224,8 +224,11 @@ func mainRun() exitCode { var httpErr api.HTTPError if errors.As(err, &httpErr) && httpErr.StatusCode == 401 { fmt.Fprintln(stderr, "Try authenticating with: gh auth login") - } else if strings.Contains(err.Error(), "Resource protected by organization SAML enforcement") { - fmt.Fprintln(stderr, "Try re-authenticating with: gh auth refresh") + } else if u := factory.SSOURL(); u != "" { + // handles organization SAML enforcement error + fmt.Fprintf(stderr, "Authorize in your web browser: %s\n", u) + } else if msg := httpErr.ScopesSuggestion(); msg != "" { + fmt.Fprintln(stderr, msg) } return exitError @@ -236,7 +239,7 @@ func mainRun() exitCode { newRelease := <-updateMessageChan if newRelease != nil { - isHomebrew := isUnderHomebrew(cmdFactory.Executable) + isHomebrew := isUnderHomebrew(cmdFactory.Executable()) if isHomebrew && isRecentRelease(newRelease.PublishedAt) { // do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core return exitOK diff --git a/cmd/gh/main_test.go b/cmd/gh/main_test.go index b428ff4b3..01552b2bd 100644 --- a/cmd/gh/main_test.go +++ b/cmd/gh/main_test.go @@ -49,7 +49,7 @@ check your internet connection or https://githubstatus.com { name: "Cobra flag error", args: args{ - err: &cmdutil.FlagError{Err: errors.New("unknown flag --foo")}, + err: cmdutil.FlagErrorf("unknown flag --foo"), cmd: cmd, debug: false, }, diff --git a/docs/install_linux.md b/docs/install_linux.md index c14273d5d..b11c2faa9 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -14,14 +14,12 @@ our release schedule. Install: ```bash -curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg +curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null sudo apt update sudo apt install gh ``` -**Note**: If you get the error _"gpg: failed to start the dirmngr '/usr/bin/dirmngr': No such file or directory"_, try installing the `dirmngr` package: `sudo apt install dirmngr`. - Upgrade: ```bash @@ -106,6 +104,20 @@ Or via [pkg(8)](https://www.freebsd.org/cgi/man.cgi?pkg(8)): pkg install gh ``` +### NetBSD/pkgsrc + +NetBSD users and those on [platforms supported by pkgsrc](https://pkgsrc.org/#index4h1) can install the [gh package](https://pkgsrc.se/net/gh): + +```bash +pkgin install gh +``` + +To install from source: + +```bash +cd /usr/pkgsrc/net/gh && make package-install +``` + ### OpenBSD In -current, or in releases starting from 7.0, OpenBSD users can install from packages: diff --git a/docs/project-layout.md b/docs/project-layout.md index 4bbdbad40..89337596a 100644 --- a/docs/project-layout.md +++ b/docs/project-layout.md @@ -24,7 +24,7 @@ commands is: pkg/cmd///.go ``` Following the above example, the main implementation for the `gh issue list` command, including its help -text, is in [pkg/cmd/issue/view/view.go](../pkg/cmd/issue/view/view.go) +text, is in [pkg/cmd/issue/list/list.go](../pkg/cmd/issue/list/list.go) Other help topics not specific to any command, for example `gh help environment`, are found in [pkg/cmd/root/help_topic.go](../pkg/cmd/root/help_topic.go). @@ -60,14 +60,14 @@ and talk through which code gets run in order. ## How to add a new command -0. First, check on our issue tracker to verify that our team had approved the plans for a new command. -1. Create a package for the new command, e.g. for a new command `gh boom` create the following directory +1. First, check on our issue tracker to verify that our team had approved the plans for a new command. +2. Create a package for the new command, e.g. for a new command `gh boom` create the following directory structure: `pkg/cmd/boom/` -2. The new package should expose a method, e.g. `NewCmdBoom()`, that accepts a `*cmdutil.Factory` type and +3. The new package should expose a method, e.g. `NewCmdBoom()`, that accepts a `*cmdutil.Factory` type and returns a `*cobra.Command`. * Any logic specific to this command should be kept within the command's package and not added to any "global" packages like `api` or `utils`. -3. Use the method from the previous step to generate the command and add it to the command tree, typically +4. Use the method from the previous step to generate the command and add it to the command tree, typically somewhere in the `NewCmdRoot()` method. ## How to write tests diff --git a/docs/releasing.md b/docs/releasing.md index 3c583a80b..e762d845e 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,6 +1,6 @@ # Releasing -Our build system automatically compiles and attaches cross-platform binaries to any git tag named `vX.Y.Z`. The automated changelog is generated from commit messages starting with “Merge pull request …” that landed between this tag and the previous one (as determined topologically by git). +Our build system automatically compiles and attaches cross-platform binaries to any git tag named `vX.Y.Z`. The changelog is [generated from git commit log](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes). Users who run official builds of `gh` on their machines will get notified about the new version within a 24 hour period. @@ -31,6 +31,6 @@ If the build fails, there is not a clean way to re-run it. The easiest way would A local release can be created for testing without creating anything official on the release page. -0. Make sure GoReleaser is installed: `brew install goreleaser` -1. `goreleaser --skip-validate --skip-publish --rm-dist` -2. Find the built products under `dist/`. +1. Make sure GoReleaser is installed: `brew install goreleaser` +2. `goreleaser --skip-validate --skip-publish --rm-dist` +3. Find the built products under `dist/`. diff --git a/docs/source.md b/docs/source.md index a8225c371..485c7671c 100644 --- a/docs/source.md +++ b/docs/source.md @@ -1,6 +1,6 @@ # Installation from source -0. Verify that you have Go 1.16+ installed +1. Verify that you have Go 1.16+ installed ```sh $ go version @@ -8,14 +8,14 @@ If `go` is not installed, follow instructions on [the Go website](https://golang.org/doc/install). -1. Clone this repository +2. Clone this repository ```sh $ git clone https://github.com/cli/cli.git gh-cli $ cd gh-cli ``` -2. Build and install +3. Build and install #### Unix-like systems ```sh @@ -33,7 +33,7 @@ ``` There is no install step available on Windows. -3. Run `gh version` to check if it worked. +4. Run `gh version` to check if it worked. #### Windows Run `bin\gh version` to check if it worked. diff --git a/git/git.go b/git/git.go index 9e3fe86fe..7a3a81437 100644 --- a/git/git.go +++ b/git/git.go @@ -84,6 +84,15 @@ func CurrentBranch() (string, error) { return "", fmt.Errorf("%sgit: %s", stderr.String(), err) } +func listRemotesForPath(path string) ([]string, error) { + remoteCmd, err := GitCommand("-C", path, "remote", "-v") + if err != nil { + return nil, err + } + output, err := run.PrepareCmd(remoteCmd).Output() + return outputLines(output), err +} + func listRemotes() ([]string, error) { remoteCmd, err := GitCommand("remote", "-v") if err != nil { @@ -298,6 +307,19 @@ func CheckoutBranch(branch string) error { return run.PrepareCmd(configCmd).Run() } +// pull changes from remote branch without version history +func Pull(remote, branch string) error { + pullCmd, err := GitCommand("pull", "--ff-only", remote, branch) + if err != nil { + return err + } + + pullCmd.Stdout = os.Stdout + pullCmd.Stderr = os.Stderr + pullCmd.Stdin = os.Stdin + return run.PrepareCmd(pullCmd).Run() +} + func parseCloneArgs(extraArgs []string) (args []string, target string) { args = extraArgs @@ -366,6 +388,31 @@ func ToplevelDir() (string, error) { } +// ToplevelDirFromPath returns the top-level given path of the current repository +func GetDirFromPath(p string) (string, error) { + showCmd, err := GitCommand("-C", p, "rev-parse", "--git-dir") + if err != nil { + return "", err + } + output, err := run.PrepareCmd(showCmd).Output() + return firstLine(output), err +} + +func PathFromRepoRoot() string { + showCmd, err := GitCommand("rev-parse", "--show-prefix") + if err != nil { + return "" + } + output, err := run.PrepareCmd(showCmd).Output() + if err != nil { + return "" + } + if path := firstLine(output); path != "" { + return path[:len(path)-1] + } + return "" +} + func outputLines(output []byte) []string { lines := strings.TrimSuffix(string(output), "\n") return strings.Split(lines, "\n") diff --git a/git/git_test.go b/git/git_test.go index c43ad50c2..979a5e243 100644 --- a/git/git_test.go +++ b/git/git_test.go @@ -100,7 +100,7 @@ func Test_CurrentBranch(t *testing.T) { result, err := CurrentBranch() if err != nil { - t.Errorf("got unexpected error: %w", err) + t.Errorf("got unexpected error: %v", err) } if result != v.Expected { t.Errorf("unexpected branch name: %s instead of %s", result, v.Expected) diff --git a/git/remote.go b/git/remote.go index f9dfbc4bd..bea81da90 100644 --- a/git/remote.go +++ b/git/remote.go @@ -35,16 +35,11 @@ func (r *Remote) String() string { return r.Name } -// Remotes gets the git remotes set for the current repo -func Remotes() (RemoteSet, error) { - list, err := listRemotes() - if err != nil { - return nil, err - } - remotes := parseRemotes(list) +func remotes(path string, remoteList []string) (RemoteSet, error) { + remotes := parseRemotes(remoteList) // this is affected by SetRemoteResolution - remoteCmd, err := GitCommand("config", "--get-regexp", `^remote\..*\.gh-resolved$`) + remoteCmd, err := GitCommand("-C", path, "config", "--get-regexp", `^remote\..*\.gh-resolved$`) if err != nil { return nil, err } @@ -70,6 +65,23 @@ func Remotes() (RemoteSet, error) { return remotes, nil } +func RemotesForPath(path string) (RemoteSet, error) { + list, err := listRemotesForPath(path) + if err != nil { + return nil, err + } + return remotes(path, list) +} + +// Remotes gets the git remotes set for the current repo +func Remotes() (RemoteSet, error) { + list, err := listRemotes() + if err != nil { + return nil, err + } + return remotes(".", list) +} + func parseRemotes(gitRemotes []string) (remotes RemoteSet) { for _, r := range gitRemotes { match := remoteRE.FindStringSubmatch(r) @@ -140,6 +152,14 @@ func AddRemote(name, u string) (*Remote, error) { }, nil } +func UpdateRemoteURL(name, u string) error { + addCmd, err := GitCommand("remote", "set-url", name, u) + if err != nil { + return err + } + return run.PrepareCmd(addCmd).Run() +} + func SetRemoteResolution(name, resolution string) error { addCmd, err := GitCommand("config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution) if err != nil { diff --git a/git/ssh_config.go b/git/ssh_config.go index a4e234a02..3c5056474 100644 --- a/git/ssh_config.go +++ b/git/ssh_config.go @@ -30,9 +30,8 @@ func (m SSHAliasMap) Translator() func(*url.URL) *url.URL { if !ok { return u } - // FIXME: cleanup domain logic - if strings.EqualFold(u.Hostname(), "github.com") && strings.EqualFold(resolvedHost, "ssh.github.com") { - return u + if strings.EqualFold(resolvedHost, "ssh.github.com") { + resolvedHost = "github.com" } newURL, _ := url.Parse(u.String()) newURL.Host = resolvedHost diff --git a/git/ssh_config_test.go b/git/ssh_config_test.go index f05ca303b..058617269 100644 --- a/git/ssh_config_test.go +++ b/git/ssh_config_test.go @@ -128,12 +128,14 @@ func Test_Translator(t *testing.T) { m := SSHAliasMap{ "gh": "github.com", "github.com": "ssh.github.com", + "my.gh.com": "ssh.github.com", } tr := m.Translator() cases := [][]string{ {"ssh://gh/o/r", "ssh://github.com/o/r"}, {"ssh://github.com/o/r", "ssh://github.com/o/r"}, + {"ssh://my.gh.com", "ssh://github.com"}, {"https://gh/o/r", "https://gh/o/r"}, } for _, c := range cases { diff --git a/git/url.go b/git/url.go index 7d4aac4d7..1a3e97fd6 100644 --- a/git/url.go +++ b/git/url.go @@ -14,6 +14,7 @@ func isSupportedProtocol(u string) bool { strings.HasPrefix(u, "git+ssh:") || strings.HasPrefix(u, "git:") || strings.HasPrefix(u, "http:") || + strings.HasPrefix(u, "git+https:") || strings.HasPrefix(u, "https:") } @@ -43,6 +44,10 @@ func ParseURL(rawURL string) (u *url.URL, err error) { u.Scheme = "ssh" } + if u.Scheme == "git+https" { + u.Scheme = "https" + } + if u.Scheme != "ssh" { return } diff --git a/git/url_test.go b/git/url_test.go index 679739b41..f5b3b50d0 100644 --- a/git/url_test.go +++ b/git/url_test.go @@ -28,11 +28,26 @@ func TestIsURL(t *testing.T) { 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: "https", url: "https://example.com/owner/repo.git", want: true, }, + { + name: "git+https", + url: "git+https://example.com/owner/repo.git", + want: true, + }, { name: "no protocol", url: "example.com/owner/repo", @@ -121,6 +136,16 @@ func TestParseURL(t *testing.T) { 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", diff --git a/go.mod b/go.mod index 1de8b09f9..adf42c900 100644 --- a/go.mod +++ b/go.mod @@ -3,38 +3,44 @@ module github.com/cli/cli/v2 go 1.16 require ( - github.com/AlecAivazis/survey/v2 v2.3.1 + github.com/AlecAivazis/survey/v2 v2.3.2 github.com/MakeNowJust/heredoc v1.0.0 - github.com/briandowns/spinner v1.11.1 - github.com/charmbracelet/glamour v0.3.0 + github.com/briandowns/spinner v1.18.0 + github.com/charmbracelet/glamour v0.4.0 github.com/cli/browser v1.1.0 - github.com/cli/oauth v0.8.0 + github.com/cli/oauth v0.9.0 github.com/cli/safeexec v1.0.0 - github.com/cpuguy83/go-md2man/v2 v2.0.0 - github.com/creack/pty v1.1.13 - github.com/gabriel-vasile/mimetype v1.1.2 - github.com/google/go-cmp v0.5.5 + github.com/cli/shurcooL-graphql v0.0.1 + github.com/cpuguy83/go-md2man/v2 v2.0.1 + github.com/creack/pty v1.1.17 + github.com/gabriel-vasile/mimetype v1.4.0 + github.com/google/go-cmp v0.5.7 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 - github.com/hashicorp/go-version v1.2.1 + github.com/gorilla/websocket v1.4.2 + github.com/hashicorp/go-multierror v1.1.1 + github.com/hashicorp/go-version v1.3.0 github.com/henvic/httpretty v0.0.6 - github.com/itchyny/gojq v0.12.4 + github.com/itchyny/gojq v0.12.6 + github.com/joho/godotenv v1.4.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/mattn/go-colorable v0.1.8 - github.com/mattn/go-isatty v0.0.13 + github.com/mattn/go-colorable v0.1.12 + github.com/mattn/go-isatty v0.0.14 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d - github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5 - github.com/muesli/termenv v0.8.1 + github.com/muesli/reflow v0.3.0 + github.com/muesli/termenv v0.9.0 + github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 + github.com/opentracing/opentracing-go v1.1.0 github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b - github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f - github.com/spf13/cobra v1.2.1 + github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a // indirect + github.com/sourcegraph/jsonrpc2 v0.1.0 + github.com/spf13/cobra v1.3.0 github.com/spf13/pflag v1.0.5 - github.com/stretchr/objx v0.1.1 // indirect github.com/stretchr/testify v1.7.0 - golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 + golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 golang.org/x/sync v0.0.0-20210220032951-036812b2e83c - golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b + golang.org/x/sys v0.0.0-20211205182925-97ca703d548d golang.org/x/term v0.0.0-20210503060354-a79de5458b56 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) -replace github.com/shurcooL/graphql => github.com/cli/shurcooL-graphql v0.0.0-20200707151639-0f7232a2bf7e +replace golang.org/x/crypto => github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03 diff --git a/go.sum b/go.sum index cf3f5b57b..b99d20715 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,15 @@ cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmW cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg= cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8= cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0= +cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY= +cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM= +cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY= +cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ= +cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI= +cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4= +cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc= +cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM= +cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= @@ -26,7 +35,7 @@ cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4g cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= -cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk= +cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY= cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= @@ -37,87 +46,116 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= -github.com/AlecAivazis/survey/v2 v2.3.1 h1:lzkuHA60pER7L4eYL8qQJor4bUWlJe4V0gqAT19tdOA= -github.com/AlecAivazis/survey/v2 v2.3.1/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg= +github.com/AlecAivazis/survey/v2 v2.3.2 h1:TqTB+aDDCLYhf9/bD2TwSO8u8jDSmMUd2SUVO4gCnU8= +github.com/AlecAivazis/survey/v2 v2.3.2/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= 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-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw= github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U= -github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI= -github.com/alecthomas/chroma v0.8.2 h1:x3zkuE2lUk/RIekyAJ3XRqSCP4zwWDfcw/YJCuCAACg= -github.com/alecthomas/chroma v0.8.2/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo= -github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0= -github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY= -github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +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/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= +github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= 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/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= -github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM= -github.com/briandowns/spinner v1.11.1 h1:OixPqDEcX3juo5AjQZAnFPbeUA0jvkp2qzB5gOZJ/L0= -github.com/briandowns/spinner v1.11.1/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ= +github.com/briandowns/spinner v1.18.0 h1:SJs0maNOs4FqhBwiJ3Gr7Z1D39/rukIVGQvpNZVHVcM= +github.com/briandowns/spinner v1.18.0/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/charmbracelet/glamour v0.3.0 h1:3H+ZrKlSg8s+WU6V7eF2eRVYt8lCueffbi7r2+ffGkc= -github.com/charmbracelet/glamour v0.3.0/go.mod h1:TzF0koPZhqq0YVBNL100cPHznAAjVj7fksX2RInwjGw= +github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/glamour v0.4.0 h1:scR+smyB7WdmrlIaff6IVlm48P48JaNM7JypM/VGl4k= +github.com/charmbracelet/glamour v0.4.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q= github.com/cli/browser v1.1.0 h1:xOZBfkfY9L9vMBgqb1YwRirGu6QFaQ5dP/vXt5ENSOY= github.com/cli/browser v1.1.0/go.mod h1:HKMQAt9t12kov91Mn7RfZxyJQQgWgyS/3SZswlZ5iTI= -github.com/cli/oauth v0.8.0 h1:YTFgPXSTvvDUFti3tR4o6q7Oll2SnQ9ztLwCAn4/IOA= -github.com/cli/oauth v0.8.0/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4= +github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03 h1:3f4uHLfWx4/WlnMPXGai03eoWAI+oGHJwr+5OXfxCr8= +github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +github.com/cli/oauth v0.9.0 h1:nxBC0Df4tUzMkqffAB+uZvisOwT3/N9FpkfdTDtafxc= +github.com/cli/oauth v0.9.0/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4= github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI= github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q= -github.com/cli/shurcooL-graphql v0.0.0-20200707151639-0f7232a2bf7e h1:aq/1jlmtZoS6nlSp3yLOTZQ50G+dzHdeRNENgE/iBew= -github.com/cli/shurcooL-graphql v0.0.0-20200707151639-0f7232a2bf7e/go.mod h1:it23pLwxmz6OyM6I5O0ATIXQS1S190Nas26L5Kahp4U= +github.com/cli/shurcooL-graphql v0.0.1 h1:/9J3t9O6p1B8zdBBtQighq5g7DQRItBwuwGh3SocsKM= +github.com/cli/shurcooL-graphql v0.0.1/go.mod h1:U7gCSuMZP/Qy7kbqkk5PrqXEeDgtfG5K+W+u8weorps= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM= -github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= -github.com/creack/pty v1.1.13 h1:rTPnd/xocYRjutMfqide2zle1u96upp1gm6eUHKi7us= -github.com/creack/pty v1.1.13/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= +github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= +github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +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.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk= -github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +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/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= -github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= +github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/gabriel-vasile/mimetype v1.1.2 h1:gaPnPcNor5aZSVCJVSGipcpbgMWiAAj9z182ocSGbHU= -github.com/gabriel-vasile/mimetype v1.1.2/go.mod h1:6CDPel/o/3/s4+bp6kIbsWATq8pmgOisOPG40CJa6To= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= +github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro= +github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= @@ -126,6 +164,7 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -144,6 +183,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= @@ -156,12 +196,15 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= +github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk= github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= @@ -173,63 +216,90 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 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/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= +github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= 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/graph-gophers/graphql-go v0.0.0-20200622220639-c1d9693c95a6/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= +github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q= -github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= +github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M= +github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms= +github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= +github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ= github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM= github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= -github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU= +github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= -github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= +github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw= +github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ= -github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I= -github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc= +github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY= +github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc= +github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE= +github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk= +github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4= 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-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ= github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= +github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= -github.com/itchyny/go-flags v1.5.0/go.mod h1:lenkYuCobuxLBAd/HGFE4LRoW8D3B6iXRQfWYJ+MNbA= -github.com/itchyny/gojq v0.12.4 h1:8zgOZWMejEWCLjbF/1mWY7hY7QEARm7dtuhC6Bp4R8o= -github.com/itchyny/gojq v0.12.4/go.mod h1:EQUSKgW/YaOxmXpAwGiowFDO4i2Rmtk5+9dFyeiymAg= +github.com/itchyny/gojq v0.12.6 h1:VjaFn59Em2wTxDNGcrRkDK9ZHMNa8IksOgL13sLL4d0= +github.com/itchyny/gojq v0.12.6/go.mod h1:ZHrkfu7A+RbZLy5J1/JKpS4poEqrzItSTGDItqsfP0A= github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU= github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A= +github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg= +github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= -github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= -github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.4 h1:5Myjjh3JY/NaAi4IsUbHADytDyl1VE1Y9PXDlL+P/VQ= github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -237,79 +307,108 @@ 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/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= -github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8= -github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= -github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA= github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg= -github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 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.6 h1:ZOvqHKtnx0fUpnbQm3m3zKFWE+DRC+XB1onh8JoEObE= -github.com/microcosm-cc/bluemonday v1.0.6/go.mod h1:HOT/6NaBlR0f9XlxD3zolN6Z3N8Lp4pvhp+jLS5ihnI= +github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y= +github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= -github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= +github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= +github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI= -github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg= -github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY= github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= -github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8= -github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5 h1:T+Fc6qGlSfM+z0JPlp+n5rijvlg6C6JYFSNaqnCifDU= -github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= -github.com/muesli/termenv v0.8.1 h1:9q230czSP3DHVpkaPDXGp0TOfAwyjyYwXlUCQxQSaBk= -github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +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.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8= +github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= +github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 h1:0FrBxrkJ0hVembTb/e4EU5Ml6vLcOusAqymmYISg5Uo= +github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38/go.mod h1:saF2fIVw4banK0H4+/EuqfFLpRnoy5S+ECwTOCcRcSU= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 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/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= -github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI= 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/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= -github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= -github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= -github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b h1:0/ecDXh/HTHRtSDSFnD2/Ta1yQ5J76ZspVY4u0/jGFk= github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= -github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= -github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= -github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= +github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a h1:KikTa6HtAK8cS1qjvUvvq4QO21QnwC+EfvB+OAuZ/ZU= +github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sourcegraph/jsonrpc2 v0.1.0 h1:ohJHjZ+PcaLxDUjqk2NC3tIGsVa5bXThe1ZheSXOjuk= +github.com/sourcegraph/jsonrpc2 v0.1.0/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= -github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw= -github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk= +github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v1.3.0 h1:R7cSvGu+Vv+qX0gW5R/85dx2kmmJT5z5NM8ifdYjdn0= +github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4= github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns= +github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -322,18 +421,19 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.3.3/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= -github.com/yuin/goldmark v1.3.5 h1:dPmz1Snjq0kmkz159iL7S6WzdahUTHnHB5M56WFVifs= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs= +github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= -go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= -go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ= +go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= +go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g= +go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -341,19 +441,10 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= -golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E= -golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -389,20 +480,22 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -424,9 +517,14 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= -golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= +golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -438,8 +536,13 @@ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 h1:0Ja1LBD+yisY6RWM/BH7TJVXWsSjs2VwBSmvSX4HdBc= -golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -454,30 +557,35 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cO golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -490,15 +598,29 @@ golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b h1:qh4f65QIVFjq9eBURLEYWqaEXmOyqdUyiBSgaXWccWk= -golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E= +golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/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-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= @@ -508,8 +630,10 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -519,7 +643,6 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3 golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= @@ -527,9 +650,9 @@ golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgw golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -563,7 +686,11 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -590,7 +717,17 @@ google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34q google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU= google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94= -google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8= +google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo= +google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4= +google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw= +google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU= +google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k= +google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE= +google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI= +google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU= +google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I= +google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -639,7 +776,29 @@ google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A= +google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= +google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24= +google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k= +google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48= +google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w= +google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY= +google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= @@ -659,7 +818,15 @@ google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA5 google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= +google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE= +google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -671,15 +838,21 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 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/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go index a2875c506..fbf0a9e34 100644 --- a/internal/authflow/flow.go +++ b/internal/authflow/flow.go @@ -27,13 +27,11 @@ type iconfig interface { Write() error } -func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice string, additionalScopes []string) (string, error) { +func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice string, additionalScopes []string, isInteractive bool) (string, error) { // TODO this probably shouldn't live in this package. It should probably be in a new package that // depends on both iostreams and config. - stderr := IO.ErrOut - cs := IO.ColorScheme() - token, userLogin, err := authFlow(hostname, IO, notice, additionalScopes) + token, userLogin, err := authFlow(hostname, IO, notice, additionalScopes, isInteractive) if err != nil { return "", err } @@ -47,19 +45,10 @@ func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice s return "", err } - err = cfg.Write() - if err != nil { - return "", err - } - - fmt.Fprintf(stderr, "%s Authentication complete. %s to continue...\n", - cs.SuccessIcon(), cs.Bold("Press Enter")) - _ = waitForEnter(IO.In) - - return token, nil + return token, cfg.Write() } -func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string) (string, string, error) { +func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string, isInteractive bool) (string, string, error) { w := IO.ErrOut cs := IO.ColorScheme() @@ -80,7 +69,7 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition } flow := &oauth.Flow{ - Hostname: oauthHost, + Host: oauth.GitHubHost(ghinstance.HostPrefix(oauthHost)), ClientID: oauthClientID, ClientSecret: oauthClientSecret, CallbackURI: callbackURI, @@ -90,7 +79,12 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition return nil }, BrowseURL: func(url string) error { - fmt.Fprintf(w, "- %s to open %s in your browser... ", cs.Bold("Press Enter"), oauthHost) + if !isInteractive { + fmt.Fprintf(w, "%s to continue in your web browser: %s\n", cs.Bold("Open this URL"), url) + return nil + } + + fmt.Fprintf(w, "%s to open %s in your browser... ", cs.Bold("Press Enter"), oauthHost) _ = waitForEnter(IO.In) // FIXME: read the browser from cmd Factory rather than recreating it @@ -103,7 +97,7 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition return nil }, WriteSuccessHTML: func(w io.Writer) { - fmt.Fprintln(w, oauthSuccessPage) + fmt.Fprint(w, oauthSuccessPage) }, HTTPClient: httpClient, Stdin: IO.In, diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go new file mode 100644 index 000000000..8de16bbb8 --- /dev/null +++ b/internal/codespaces/api/api.go @@ -0,0 +1,688 @@ +package api + +// For descriptions of service interfaces, see: +// - https://online.visualstudio.com/api/swagger (for visualstudio.com) +// - https://docs.github.com/en/rest/reference/repos (for api.github.com) +// - https://github.com/github/github/blob/master/app/api/codespaces.rb (for vscs_internal) +// TODO(adonovan): replace the last link with a public doc URL when available. + +// TODO(adonovan): a possible reorganization would be to split this +// file into three internal packages, one per backend service, and to +// rename api.API to github.Client: +// +// - github.GetUser(github.Client) +// - github.GetRepository(Client) +// - github.ReadFile(Client, nwo, branch, path) // was GetCodespaceRepositoryContents +// - github.AuthorizedKeys(Client, user) +// - codespaces.Create(Client, user, repo, sku, branch, location) +// - codespaces.Delete(Client, user, token, name) +// - codespaces.Get(Client, token, owner, name) +// - codespaces.GetMachineTypes(Client, user, repo, branch, location) +// - codespaces.GetToken(Client, login, name) +// - codespaces.List(Client, user) +// - codespaces.Start(Client, token, codespace) +// - visualstudio.GetRegionLocation(http.Client) // no dependency on github +// +// This would make the meaning of each operation clearer. + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "reflect" + "regexp" + "strconv" + "strings" + "time" + + "github.com/cli/cli/v2/api" + "github.com/opentracing/opentracing-go" +) + +const ( + githubServer = "https://github.com" + githubAPI = "https://api.github.com" + vscsAPI = "https://online.visualstudio.com" +) + +// API is the interface to the codespace service. +type API struct { + client httpClient + vscsAPI string + githubAPI string + githubServer string +} + +type httpClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// New creates a new API client connecting to the configured endpoints with the HTTP client. +func New(serverURL, apiURL, vscsURL string, httpClient httpClient) *API { + if serverURL == "" { + serverURL = githubServer + } + if apiURL == "" { + apiURL = githubAPI + } + if vscsURL == "" { + vscsURL = vscsAPI + } + return &API{ + client: httpClient, + vscsAPI: strings.TrimSuffix(vscsURL, "/"), + githubAPI: strings.TrimSuffix(apiURL, "/"), + githubServer: strings.TrimSuffix(serverURL, "/"), + } +} + +// User represents a GitHub user. +type User struct { + Login string `json:"login"` +} + +// GetUser returns the user associated with the given token. +func (a *API) GetUser(ctx context.Context) (*User, error) { + req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/user", nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + a.setHeaders(req) + resp, err := a.do(ctx, req, "/user") + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + var response User + if err := json.Unmarshal(b, &response); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %w", err) + } + + return &response, nil +} + +// Repository represents a GitHub repository. +type Repository struct { + ID int `json:"id"` + FullName string `json:"full_name"` + DefaultBranch string `json:"default_branch"` +} + +// GetRepository returns the repository associated with the given owner and name. +func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error) { + req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+strings.ToLower(nwo), nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + a.setHeaders(req) + resp, err := a.do(ctx, req, "/repos/*") + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + var response Repository + if err := json.Unmarshal(b, &response); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %w", err) + } + + return &response, nil +} + +// Codespace represents a codespace. +type Codespace struct { + Name string `json:"name"` + CreatedAt string `json:"created_at"` + LastUsedAt string `json:"last_used_at"` + Owner User `json:"owner"` + Repository Repository `json:"repository"` + State string `json:"state"` + GitStatus CodespaceGitStatus `json:"git_status"` + Connection CodespaceConnection `json:"connection"` +} + +type CodespaceGitStatus struct { + Ahead int `json:"ahead"` + Behind int `json:"behind"` + Ref string `json:"ref"` + HasUnpushedChanges bool `json:"has_unpushed_changes"` + HasUncommitedChanges bool `json:"has_uncommited_changes"` +} + +const ( + // CodespaceStateAvailable is the state for a running codespace environment. + CodespaceStateAvailable = "Available" + // CodespaceStateShutdown is the state for a shutdown codespace environment. + CodespaceStateShutdown = "Shutdown" + // CodespaceStateStarting is the state for a starting codespace environment. + CodespaceStateStarting = "Starting" +) + +type CodespaceConnection struct { + SessionID string `json:"sessionId"` + SessionToken string `json:"sessionToken"` + RelayEndpoint string `json:"relayEndpoint"` + RelaySAS string `json:"relaySas"` + HostPublicKeys []string `json:"hostPublicKeys"` +} + +// CodespaceFields is the list of exportable fields for a codespace. +var CodespaceFields = []string{ + "name", + "owner", + "repository", + "state", + "gitStatus", + "createdAt", + "lastUsedAt", +} + +func (c *Codespace) ExportData(fields []string) map[string]interface{} { + v := reflect.ValueOf(c).Elem() + data := map[string]interface{}{} + + for _, f := range fields { + switch f { + case "owner": + data[f] = c.Owner.Login + case "repository": + data[f] = c.Repository.FullName + case "gitStatus": + data[f] = map[string]interface{}{ + "ref": c.GitStatus.Ref, + "hasUnpushedChanges": c.GitStatus.HasUnpushedChanges, + "hasUncommitedChanges": c.GitStatus.HasUncommitedChanges, + } + default: + sf := v.FieldByNameFunc(func(s string) bool { + return strings.EqualFold(f, s) + }) + data[f] = sf.Interface() + } + } + + return data +} + +// ListCodespaces returns a list of codespaces for the user. Pass a negative limit to request all pages from +// the API until all codespaces have been fetched. +func (a *API) ListCodespaces(ctx context.Context, limit int) (codespaces []*Codespace, err error) { + perPage := 100 + if limit > 0 && limit < 100 { + perPage = limit + } + + listURL := fmt.Sprintf("%s/user/codespaces?per_page=%d", a.githubAPI, perPage) + for { + req, err := http.NewRequest(http.MethodGet, listURL, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + a.setHeaders(req) + + resp, err := a.do(ctx, req, "/user/codespaces") + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, api.HandleHTTPError(resp) + } + + var response struct { + Codespaces []*Codespace `json:"codespaces"` + } + dec := json.NewDecoder(resp.Body) + if err := dec.Decode(&response); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %w", err) + } + + nextURL := findNextPage(resp.Header.Get("Link")) + codespaces = append(codespaces, response.Codespaces...) + + if nextURL == "" || (limit > 0 && len(codespaces) >= limit) { + break + } + + if newPerPage := limit - len(codespaces); limit > 0 && newPerPage < 100 { + u, _ := url.Parse(nextURL) + q := u.Query() + q.Set("per_page", strconv.Itoa(newPerPage)) + u.RawQuery = q.Encode() + listURL = u.String() + } else { + listURL = nextURL + } + } + + return codespaces, nil +} + +var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) + +func findNextPage(linkValue string) string { + for _, m := range linkRE.FindAllStringSubmatch(linkValue, -1) { + if len(m) > 2 && m[2] == "next" { + return m[1] + } + } + return "" +} + +// GetCodespace returns the user codespace based on the provided name. +// If the codespace is not found, an error is returned. +// If includeConnection is true, it will return the connection information for the codespace. +func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeConnection bool) (*Codespace, error) { + req, err := http.NewRequest( + http.MethodGet, + a.githubAPI+"/user/codespaces/"+codespaceName, + nil, + ) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + if includeConnection { + q := req.URL.Query() + q.Add("internal", "true") + q.Add("refresh", "true") + req.URL.RawQuery = q.Encode() + } + + a.setHeaders(req) + resp, err := a.do(ctx, req, "/user/codespaces/*") + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + var response Codespace + if err := json.Unmarshal(b, &response); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %w", err) + } + + return &response, nil +} + +// StartCodespace starts a codespace for the user. +// If the codespace is already running, the returned error from the API is ignored. +func (a *API) StartCodespace(ctx context.Context, codespaceName string) error { + req, err := http.NewRequest( + http.MethodPost, + a.githubAPI+"/user/codespaces/"+codespaceName+"/start", + nil, + ) + if err != nil { + return fmt.Errorf("error creating request: %w", err) + } + + a.setHeaders(req) + resp, err := a.do(ctx, req, "/user/codespaces/*/start") + if err != nil { + return fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode == http.StatusConflict { + // 409 means the codespace is already running which we can safely ignore + return nil + } + return api.HandleHTTPError(resp) + } + + return nil +} + +func (a *API) StopCodespace(ctx context.Context, codespaceName string) error { + req, err := http.NewRequest( + http.MethodPost, + a.githubAPI+"/user/codespaces/"+codespaceName+"/stop", + nil, + ) + if err != nil { + return fmt.Errorf("error creating request: %w", err) + } + + a.setHeaders(req) + resp, err := a.do(ctx, req, "/user/codespaces/*/stop") + if err != nil { + return fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return api.HandleHTTPError(resp) + } + + return nil +} + +type getCodespaceRegionLocationResponse struct { + Current string `json:"current"` +} + +// GetCodespaceRegionLocation returns the closest codespace location for the user. +func (a *API) GetCodespaceRegionLocation(ctx context.Context) (string, error) { + req, err := http.NewRequest(http.MethodGet, a.vscsAPI+"/api/v1/locations", nil) + if err != nil { + return "", fmt.Errorf("error creating request: %w", err) + } + + resp, err := a.do(ctx, req, req.URL.String()) + if err != nil { + return "", fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("error reading response body: %w", err) + } + + var response getCodespaceRegionLocationResponse + if err := json.Unmarshal(b, &response); err != nil { + return "", fmt.Errorf("error unmarshaling response: %w", err) + } + + return response.Current, nil +} + +type Machine struct { + Name string `json:"name"` + DisplayName string `json:"display_name"` + PrebuildAvailability string `json:"prebuild_availability"` +} + +// GetCodespacesMachines returns the codespaces machines for the given repo, branch and location. +func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*Machine, error) { + reqURL := fmt.Sprintf("%s/repositories/%d/codespaces/machines", a.githubAPI, repoID) + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + q := req.URL.Query() + q.Add("location", location) + q.Add("ref", branch) + req.URL.RawQuery = q.Encode() + + a.setHeaders(req) + resp, err := a.do(ctx, req, "/repositories/*/codespaces/machines") + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + var response struct { + Machines []*Machine `json:"machines"` + } + if err := json.Unmarshal(b, &response); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %w", err) + } + + return response.Machines, nil +} + +// CreateCodespaceParams are the required parameters for provisioning a Codespace. +type CreateCodespaceParams struct { + RepositoryID int + IdleTimeoutMinutes int + Branch string + Machine string + Location string +} + +// CreateCodespace creates a codespace with the given parameters and returns a non-nil error if it +// fails to create. +func (a *API) CreateCodespace(ctx context.Context, params *CreateCodespaceParams) (*Codespace, error) { + codespace, err := a.startCreate(ctx, params) + if err != errProvisioningInProgress { + return codespace, err + } + + // errProvisioningInProgress indicates that codespace creation did not complete + // within the GitHub API RPC time limit (10s), so it continues asynchronously. + // We must poll the server to discover the outcome. + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + codespace, err = a.GetCodespace(ctx, codespace.Name, false) + if err != nil { + return nil, fmt.Errorf("failed to get codespace: %w", err) + } + + // we continue to poll until the codespace shows as provisioned + if codespace.State != CodespaceStateAvailable { + continue + } + + return codespace, nil + } + } +} + +type startCreateRequest struct { + RepositoryID int `json:"repository_id"` + IdleTimeoutMinutes int `json:"idle_timeout_minutes,omitempty"` + Ref string `json:"ref"` + Location string `json:"location"` + Machine string `json:"machine"` +} + +var errProvisioningInProgress = errors.New("provisioning in progress") + +// startCreate starts the creation of a codespace. +// It may return success or an error, or errProvisioningInProgress indicating that the operation +// did not complete before the GitHub API's time limit for RPCs (10s), in which case the caller +// must poll the server to learn the outcome. +func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*Codespace, error) { + if params == nil { + return nil, errors.New("startCreate missing parameters") + } + + requestBody, err := json.Marshal(startCreateRequest{ + RepositoryID: params.RepositoryID, + IdleTimeoutMinutes: params.IdleTimeoutMinutes, + Ref: params.Branch, + Location: params.Location, + Machine: params.Machine, + }) + if err != nil { + return nil, fmt.Errorf("error marshaling request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, a.githubAPI+"/user/codespaces", bytes.NewBuffer(requestBody)) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + a.setHeaders(req) + resp, err := a.do(ctx, req, "/user/codespaces") + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusAccepted { + return nil, errProvisioningInProgress // RPC finished before result of creation known + } else if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + var response Codespace + if err := json.Unmarshal(b, &response); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %w", err) + } + + return &response, nil +} + +// DeleteCodespace deletes the given codespace. +func (a *API) DeleteCodespace(ctx context.Context, codespaceName string) error { + req, err := http.NewRequest(http.MethodDelete, a.githubAPI+"/user/codespaces/"+codespaceName, nil) + if err != nil { + return fmt.Errorf("error creating request: %w", err) + } + + a.setHeaders(req) + resp, err := a.do(ctx, req, "/user/codespaces/*") + if err != nil { + return fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted { + return api.HandleHTTPError(resp) + } + + return nil +} + +type getCodespaceRepositoryContentsResponse struct { + Content string `json:"content"` +} + +func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Codespace, path string) ([]byte, error) { + req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+codespace.Repository.FullName+"/contents/"+path, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + q := req.URL.Query() + q.Add("ref", codespace.GitStatus.Ref) + req.URL.RawQuery = q.Encode() + + a.setHeaders(req) + resp, err := a.do(ctx, req, "/repos/*/contents/*") + if err != nil { + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } else if resp.StatusCode != http.StatusOK { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + var response getCodespaceRepositoryContentsResponse + if err := json.Unmarshal(b, &response); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %w", err) + } + + decoded, err := base64.StdEncoding.DecodeString(response.Content) + if err != nil { + return nil, fmt.Errorf("error decoding content: %w", err) + } + + return decoded, nil +} + +// AuthorizedKeys returns the public keys (in ~/.ssh/authorized_keys +// format) registered by the specified GitHub user. +func (a *API) AuthorizedKeys(ctx context.Context, user string) ([]byte, error) { + url := fmt.Sprintf("%s/%s.keys", a.githubServer, user) + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := a.do(ctx, req, "/user.keys") + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned %s", resp.Status) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + return b, nil +} + +// do executes the given request and returns the response. It creates an +// opentracing span to track the length of the request. +func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http.Response, error) { + // TODO(adonovan): use NewRequestWithContext(ctx) and drop ctx parameter. + span, ctx := opentracing.StartSpanFromContext(ctx, spanName) + defer span.Finish() + req = req.WithContext(ctx) + return a.client.Do(req) +} + +// setHeaders sets the required headers for the API. +func (a *API) setHeaders(req *http.Request) { + req.Header.Set("Accept", "application/vnd.github.v3+json") +} diff --git a/internal/codespaces/api/api_test.go b/internal/codespaces/api/api_test.go new file mode 100644 index 000000000..6dcd06a04 --- /dev/null +++ b/internal/codespaces/api/api_test.go @@ -0,0 +1,116 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" +) + +func generateCodespaceList(start int, end int) []*Codespace { + codespacesList := []*Codespace{} + for i := start; i < end; i++ { + codespacesList = append(codespacesList, &Codespace{ + Name: fmt.Sprintf("codespace-%d", i), + }) + } + return codespacesList +} + +func createFakeListEndpointServer(t *testing.T, initalTotal int, finalTotal int) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/user/codespaces" { + t.Fatal("Incorrect path") + } + + page := 1 + if r.URL.Query().Get("page") != "" { + page, _ = strconv.Atoi(r.URL.Query().Get("page")) + } + + per_page := 0 + if r.URL.Query().Get("per_page") != "" { + per_page, _ = strconv.Atoi(r.URL.Query().Get("per_page")) + } + + response := struct { + Codespaces []*Codespace `json:"codespaces"` + TotalCount int `json:"total_count"` + }{ + Codespaces: []*Codespace{}, + TotalCount: finalTotal, + } + + switch page { + case 1: + response.Codespaces = generateCodespaceList(0, per_page) + response.TotalCount = initalTotal + w.Header().Set("Link", fmt.Sprintf(`; rel="last", ; rel="next"`, r.Host, per_page)) + case 2: + response.Codespaces = generateCodespaceList(per_page, per_page*2) + response.TotalCount = finalTotal + w.Header().Set("Link", fmt.Sprintf(`; rel="next"`, r.Host, per_page)) + case 3: + response.Codespaces = generateCodespaceList(per_page*2, per_page*3-per_page/2) + response.TotalCount = finalTotal + default: + t.Fatal("Should not check extra page") + } + + data, _ := json.Marshal(response) + fmt.Fprint(w, string(data)) + })) +} + +func TestListCodespaces_limited(t *testing.T) { + svr := createFakeListEndpointServer(t, 200, 200) + defer svr.Close() + + api := API{ + githubAPI: svr.URL, + client: &http.Client{}, + } + ctx := context.TODO() + codespaces, err := api.ListCodespaces(ctx, 200) + if err != nil { + t.Fatal(err) + } + + if len(codespaces) != 200 { + t.Fatalf("expected 200 codespace, got %d", len(codespaces)) + } + if codespaces[0].Name != "codespace-0" { + t.Fatalf("expected codespace-0, got %s", codespaces[0].Name) + } + if codespaces[199].Name != "codespace-199" { + t.Fatalf("expected codespace-199, got %s", codespaces[0].Name) + } +} + +func TestListCodespaces_unlimited(t *testing.T) { + svr := createFakeListEndpointServer(t, 200, 200) + defer svr.Close() + + api := API{ + githubAPI: svr.URL, + client: &http.Client{}, + } + ctx := context.TODO() + codespaces, err := api.ListCodespaces(ctx, -1) + if err != nil { + t.Fatal(err) + } + + if len(codespaces) != 250 { + t.Fatalf("expected 250 codespace, got %d", len(codespaces)) + } + if codespaces[0].Name != "codespace-0" { + t.Fatalf("expected codespace-0, got %s", codespaces[0].Name) + } + if codespaces[249].Name != "codespace-249" { + t.Fatalf("expected codespace-249, got %s", codespaces[0].Name) + } +} diff --git a/internal/codespaces/codespaces.go b/internal/codespaces/codespaces.go new file mode 100644 index 000000000..330a6a772 --- /dev/null +++ b/internal/codespaces/codespaces.go @@ -0,0 +1,73 @@ +package codespaces + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/liveshare" +) + +func connectionReady(codespace *api.Codespace) bool { + return codespace.Connection.SessionID != "" && + codespace.Connection.SessionToken != "" && + codespace.Connection.RelayEndpoint != "" && + codespace.Connection.RelaySAS != "" && + codespace.State == api.CodespaceStateAvailable +} + +type apiClient interface { + GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) + StartCodespace(ctx context.Context, name string) error +} + +type progressIndicator interface { + StartProgressIndicatorWithLabel(s string) + StopProgressIndicator() +} + +type logger interface { + Println(v ...interface{}) + Printf(f string, v ...interface{}) +} + +// ConnectToLiveshare waits for a Codespace to become running, +// and connects to it using a Live Share session. +func ConnectToLiveshare(ctx context.Context, progress progressIndicator, sessionLogger logger, apiClient apiClient, codespace *api.Codespace) (sess *liveshare.Session, err error) { + if codespace.State != api.CodespaceStateAvailable { + progress.StartProgressIndicatorWithLabel("Starting codespace") + if err := apiClient.StartCodespace(ctx, codespace.Name); err != nil { + return nil, fmt.Errorf("error starting codespace: %w", err) + } + } + + for retries := 0; !connectionReady(codespace); retries++ { + if retries > 1 { + time.Sleep(1 * time.Second) + } + + if retries == 30 { + return nil, errors.New("timed out while waiting for the codespace to start") + } + + codespace, err = apiClient.GetCodespace(ctx, codespace.Name, true) + if err != nil { + return nil, fmt.Errorf("error getting codespace: %w", err) + } + } + + progress.StartProgressIndicatorWithLabel("Connecting to codespace") + defer progress.StopProgressIndicator() + + return liveshare.Connect(ctx, liveshare.Options{ + ClientName: "gh", + SessionID: codespace.Connection.SessionID, + SessionToken: codespace.Connection.SessionToken, + RelaySAS: codespace.Connection.RelaySAS, + RelayEndpoint: codespace.Connection.RelayEndpoint, + HostPublicKeys: codespace.Connection.HostPublicKeys, + Logger: sessionLogger, + }) +} diff --git a/internal/codespaces/ssh.go b/internal/codespaces/ssh.go new file mode 100644 index 000000000..1096014e7 --- /dev/null +++ b/internal/codespaces/ssh.go @@ -0,0 +1,121 @@ +package codespaces + +import ( + "context" + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +type printer interface { + Printf(fmt string, v ...interface{}) +} + +// Shell runs an interactive secure shell over an existing +// port-forwarding session. It runs until the shell is terminated +// (including by cancellation of the context). +func Shell(ctx context.Context, p printer, sshArgs []string, port int, destination string, usingCustomPort bool) error { + cmd, connArgs, err := newSSHCommand(ctx, port, destination, sshArgs) + if err != nil { + return fmt.Errorf("failed to create ssh command: %w", err) + } + + if usingCustomPort { + p.Printf("Connection Details: ssh %s %s", destination, connArgs) + } + + return cmd.Run() +} + +// Copy runs an scp command over the specified port. The arguments may +// include flags and non-flags, optionally separated by "--". +// +// Remote files indicated by a "remote:" prefix are resolved relative +// to the remote user's home directory, and are subject to shell expansion +// on the remote host; see https://lwn.net/Articles/835962/. +func Copy(ctx context.Context, scpArgs []string, port int, destination string) error { + // Beware: invalid syntax causes scp to exit 1 with + // no error message, so don't let that happen. + cmd := exec.CommandContext(ctx, "scp", + "-P", strconv.Itoa(port), + "-o", "NoHostAuthenticationForLocalhost=yes", + "-C", // compression + ) + for _, arg := range scpArgs { + // Replace "remote:" prefix with (e.g.) "root@localhost:". + if rest := strings.TrimPrefix(arg, "remote:"); rest != arg { + arg = destination + ":" + rest + } + cmd.Args = append(cmd.Args, arg) + } + cmd.Stdin = nil + cmd.Stdout = os.Stderr + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// NewRemoteCommand returns an exec.Cmd that will securely run a shell +// command on the remote machine. +func NewRemoteCommand(ctx context.Context, tunnelPort int, destination string, sshArgs ...string) (*exec.Cmd, error) { + cmd, _, err := newSSHCommand(ctx, tunnelPort, destination, sshArgs) + return cmd, err +} + +// newSSHCommand populates an exec.Cmd to run a command (or if blank, +// an interactive shell) over ssh. +func newSSHCommand(ctx context.Context, port int, dst string, cmdArgs []string) (*exec.Cmd, []string, error) { + connArgs := []string{"-p", strconv.Itoa(port), "-o", "NoHostAuthenticationForLocalhost=yes"} + + // The ssh command syntax is: ssh [flags] user@host command [args...] + // There is no way to specify the user@host destination as a flag. + // Unfortunately, that means we need to know which user-provided words are + // SSH flags and which are command arguments so that we can place + // them before or after the destination, and that means we need to know all + // the flags and their arities. + cmdArgs, command, err := parseSSHArgs(cmdArgs) + if err != nil { + return nil, nil, err + } + + cmdArgs = append(cmdArgs, connArgs...) + cmdArgs = append(cmdArgs, "-C") // Compression + cmdArgs = append(cmdArgs, dst) // user@host + + if command != nil { + cmdArgs = append(cmdArgs, command...) + } + + cmd := exec.CommandContext(ctx, "ssh", cmdArgs...) + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + cmd.Stderr = os.Stderr + + return cmd, connArgs, nil +} + +// parseSSHArgs parses SSH arguments into two distinct slices of flags and command. +// It returns an error if a unary flag is provided without an argument. +func parseSSHArgs(args []string) (cmdArgs, command []string, err error) { + for i := 0; i < len(args); i++ { + arg := args[i] + + // if we've started parsing the command, set it to the rest of the args + if !strings.HasPrefix(arg, "-") { + command = args[i:] + break + } + + cmdArgs = append(cmdArgs, arg) + if len(arg) == 2 && strings.Contains("bcDeFIiLlmOopRSWw", arg[1:2]) { + if i++; i == len(args) { + return nil, nil, fmt.Errorf("ssh flag: %s requires an argument", arg) + } + + cmdArgs = append(cmdArgs, args[i]) + } + } + + return cmdArgs, command, nil +} diff --git a/internal/codespaces/ssh_test.go b/internal/codespaces/ssh_test.go new file mode 100644 index 000000000..c804f6000 --- /dev/null +++ b/internal/codespaces/ssh_test.go @@ -0,0 +1,105 @@ +package codespaces + +import ( + "fmt" + "testing" +) + +func TestParseSSHArgs(t *testing.T) { + type testCase struct { + Args []string + ParsedArgs []string + Command []string + Error string + } + + testCases := []testCase{ + {}, // empty test case + { + Args: []string{"-X", "-Y"}, + ParsedArgs: []string{"-X", "-Y"}, + Command: nil, + }, + { + Args: []string{"-X", "-Y", "-o", "someoption=test"}, + ParsedArgs: []string{"-X", "-Y", "-o", "someoption=test"}, + Command: nil, + }, + { + Args: []string{"-X", "-Y", "-o", "someoption=test", "somecommand"}, + ParsedArgs: []string{"-X", "-Y", "-o", "someoption=test"}, + Command: []string{"somecommand"}, + }, + { + Args: []string{"-X", "-Y", "-o", "someoption=test", "echo", "test"}, + ParsedArgs: []string{"-X", "-Y", "-o", "someoption=test"}, + Command: []string{"echo", "test"}, + }, + { + Args: []string{"somecommand"}, + ParsedArgs: []string{}, + Command: []string{"somecommand"}, + }, + { + Args: []string{"echo", "test"}, + ParsedArgs: []string{}, + Command: []string{"echo", "test"}, + }, + { + Args: []string{"-v", "echo", "hello", "world"}, + ParsedArgs: []string{"-v"}, + Command: []string{"echo", "hello", "world"}, + }, + { + Args: []string{"-L", "-l"}, + ParsedArgs: []string{"-L", "-l"}, + Command: nil, + }, + { + Args: []string{"-v", "echo", "-n", "test"}, + ParsedArgs: []string{"-v"}, + Command: []string{"echo", "-n", "test"}, + }, + { + Args: []string{"-v", "echo", "-b", "test"}, + ParsedArgs: []string{"-v"}, + Command: []string{"echo", "-b", "test"}, + }, + { + Args: []string{"-b"}, + ParsedArgs: nil, + Command: nil, + Error: "ssh flag: -b requires an argument", + }, + } + + for _, tcase := range testCases { + args, command, err := parseSSHArgs(tcase.Args) + if tcase.Error != "" { + if err == nil { + t.Errorf("expected error and got nil: %#v", tcase) + } + + if err.Error() != tcase.Error { + t.Errorf("error does not match expected error, got: '%s', expected: '%s'", err.Error(), tcase.Error) + } + + continue + } + + if err != nil { + t.Errorf("unexpected error: %v on test case: %#v", err, tcase) + continue + } + + argsStr, parsedArgsStr := fmt.Sprintf("%s", args), fmt.Sprintf("%s", tcase.ParsedArgs) + if argsStr != parsedArgsStr { + t.Errorf("args do not match parsed args. got: '%s', expected: '%s'", argsStr, parsedArgsStr) + } + + commandStr, parsedCommandStr := fmt.Sprintf("%s", command), fmt.Sprintf("%s", tcase.Command) + if commandStr != parsedCommandStr { + t.Errorf("command does not match parsed command. got: '%s', expected: '%s'", commandStr, parsedCommandStr) + } + } +} diff --git a/internal/codespaces/states.go b/internal/codespaces/states.go new file mode 100644 index 000000000..688fd063f --- /dev/null +++ b/internal/codespaces/states.go @@ -0,0 +1,126 @@ +package codespaces + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io/ioutil" + "log" + "net" + "strings" + "time" + + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/liveshare" +) + +// PostCreateStateStatus is a string value representing the different statuses a state can have. +type PostCreateStateStatus string + +func (p PostCreateStateStatus) String() string { + return strings.Title(string(p)) +} + +const ( + PostCreateStateRunning PostCreateStateStatus = "running" + PostCreateStateSuccess PostCreateStateStatus = "succeeded" + PostCreateStateFailed PostCreateStateStatus = "failed" +) + +// PostCreateState is a combination of a state and status value that is captured +// during codespace creation. +type PostCreateState struct { + Name string `json:"name"` + Status PostCreateStateStatus `json:"status"` +} + +// PollPostCreateStates watches for state changes in a codespace, +// and calls the supplied poller for each batch of state changes. +// It runs until it encounters an error, including cancellation of the context. +func PollPostCreateStates(ctx context.Context, progress progressIndicator, apiClient apiClient, codespace *api.Codespace, poller func([]PostCreateState)) (err error) { + noopLogger := log.New(ioutil.Discard, "", 0) + + session, err := ConnectToLiveshare(ctx, progress, noopLogger, apiClient, codespace) + if err != nil { + return fmt.Errorf("connect to codespace: %w", err) + } + defer func() { + if closeErr := session.Close(); err == nil { + err = closeErr + } + }() + + // Ensure local port is listening before client (getPostCreateOutput) connects. + listen, err := net.Listen("tcp", "127.0.0.1:0") // arbitrary port + if err != nil { + return err + } + localPort := listen.Addr().(*net.TCPAddr).Port + + progress.StartProgressIndicatorWithLabel("Fetching SSH Details") + defer progress.StopProgressIndicator() + remoteSSHServerPort, sshUser, err := session.StartSSHServer(ctx) + if err != nil { + return fmt.Errorf("error getting ssh server details: %w", err) + } + + progress.StartProgressIndicatorWithLabel("Fetching status") + tunnelClosed := make(chan error, 1) // buffered to avoid sender stuckness + go func() { + fwd := liveshare.NewPortForwarder(session, "sshd", remoteSSHServerPort, false) + tunnelClosed <- fwd.ForwardToListener(ctx, listen) // error is non-nil + }() + + t := time.NewTicker(1 * time.Second) + defer t.Stop() + + for ticks := 0; ; ticks++ { + select { + case <-ctx.Done(): + return ctx.Err() + + case err := <-tunnelClosed: + return fmt.Errorf("connection failed: %w", err) + + case <-t.C: + states, err := getPostCreateOutput(ctx, localPort, sshUser) + // There is an active progress indicator before the first tick + // to show that we are fetching statuses. + // Once the first tick happens, we stop the indicator and let + // the subsequent post create states manage their own progress. + if ticks == 0 { + progress.StopProgressIndicator() + } + if err != nil { + return fmt.Errorf("get post create output: %w", err) + } + + poller(states) + } + } +} + +func getPostCreateOutput(ctx context.Context, tunnelPort int, user string) ([]PostCreateState, error) { + cmd, err := NewRemoteCommand( + ctx, tunnelPort, fmt.Sprintf("%s@localhost", user), + "cat /workspaces/.codespaces/shared/postCreateOutput.json", + ) + if err != nil { + return nil, fmt.Errorf("remote command: %w", err) + } + + stdout := new(bytes.Buffer) + cmd.Stdout = stdout + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("run command: %w", err) + } + var output struct { + Steps []PostCreateState `json:"steps"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + return nil, fmt.Errorf("unmarshal output: %w", err) + } + + return output.Steps, nil +} diff --git a/internal/config/config_file.go b/internal/config/config_file.go index f1f9a4d9c..a1860d940 100644 --- a/internal/config/config_file.go +++ b/internal/config/config_file.go @@ -49,7 +49,7 @@ func ConfigDir() string { } // State path precedence -// 1. XDG_CONFIG_HOME +// 1. XDG_STATE_HOME // 2. LocalAppData (windows only) // 3. HOME func StateDir() string { diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index 4c35f24f9..100c065f3 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -80,13 +80,13 @@ example.com: `)() config, err := parseConfig("config.yml") assert.NoError(t, err) - val, err := config.Get("example.com", "git_protocol") + val, err := config.GetOrDefault("example.com", "git_protocol") assert.NoError(t, err) assert.Equal(t, "https", val) - val, err = config.Get("github.com", "git_protocol") + val, err = config.GetOrDefault("github.com", "git_protocol") assert.NoError(t, err) assert.Equal(t, "ssh", val) - val, err = config.Get("nonexistent.io", "git_protocol") + val, err = config.GetOrDefault("nonexistent.io", "git_protocol") assert.NoError(t, err) assert.Equal(t, "ssh", val) } diff --git a/internal/config/config_map.go b/internal/config/config_map.go index 8afaf3a4c..c391bc486 100644 --- a/internal/config/config_map.go +++ b/internal/config/config_map.go @@ -6,7 +6,7 @@ import ( "gopkg.in/yaml.v3" ) -// This type implements a low-level get/set config that is backed by an in-memory tree of Yaml +// This type implements a low-level get/set config that is backed by an in-memory tree of yaml // nodes. It allows us to interact with a yaml-based config programmatically, preserving any // comments that were present when the yaml was parsed. type ConfigMap struct { @@ -37,41 +37,41 @@ func (cm *ConfigMap) GetStringValue(key string) (string, error) { func (cm *ConfigMap) SetStringValue(key, value string) error { entry, err := cm.FindEntry(key) + if err == nil { + entry.ValueNode.Value = value + return nil + } var notFound *NotFoundError - - valueNode := entry.ValueNode - - if err != nil && errors.As(err, ¬Found) { - keyNode := &yaml.Node{ - Kind: yaml.ScalarNode, - Value: key, - } - valueNode = &yaml.Node{ - Kind: yaml.ScalarNode, - Tag: "!!str", - Value: "", - } - - cm.Root.Content = append(cm.Root.Content, keyNode, valueNode) - } else if err != nil { + if err != nil && !errors.As(err, ¬Found) { return err } - valueNode.Value = value + keyNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Value: key, + } + valueNode := &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!str", + Value: value, + } + cm.Root.Content = append(cm.Root.Content, keyNode, valueNode) return nil } -func (cm *ConfigMap) FindEntry(key string) (ce *ConfigEntry, err error) { - err = nil +func (cm *ConfigMap) FindEntry(key string) (*ConfigEntry, error) { + ce := &ConfigEntry{} - ce = &ConfigEntry{} + if cm.Empty() { + return ce, &NotFoundError{errors.New("not found")} + } - // Content slice goes [key1, value1, key2, value2, ...] + // Content slice goes [key1, value1, key2, value2, ...]. topLevelPairs := cm.Root.Content for i, v := range topLevelPairs { - // Skip every other slice item since we only want to check against keys + // Skip every other slice item since we only want to check against keys. if i%2 != 0 { continue } @@ -81,7 +81,7 @@ func (cm *ConfigMap) FindEntry(key string) (ce *ConfigEntry, err error) { if i+1 < len(topLevelPairs) { ce.ValueNode = topLevelPairs[i+1] } - return + return ce, nil } } @@ -89,14 +89,23 @@ func (cm *ConfigMap) FindEntry(key string) (ce *ConfigEntry, err error) { } func (cm *ConfigMap) RemoveEntry(key string) { + if cm.Empty() { + return + } + newContent := []*yaml.Node{} - content := cm.Root.Content - for i := 0; i < len(content); i++ { - if content[i].Value == key { - i++ // skip the next node which is this key's value + var skipNext bool + for i, v := range cm.Root.Content { + if skipNext { + skipNext = false + continue + } + if i%2 != 0 || v.Value != key { + newContent = append(newContent, v) } else { - newContent = append(newContent, content[i]) + // Don't append current node and skip the next which is this key's value. + skipNext = true } } diff --git a/internal/config/config_map_test.go b/internal/config/config_map_test.go index c504e4cbc..4dc49d01b 100644 --- a/internal/config/config_map_test.go +++ b/internal/config/config_map_test.go @@ -1,7 +1,6 @@ package config import ( - "fmt" "testing" "github.com/stretchr/testify/assert" @@ -46,12 +45,135 @@ func TestFindEntry(t *testing.T) { return } assert.NoError(t, err) - fmt.Println(out) assert.Equal(t, tt.output, out.ValueNode.Value) }) } } +func TestEmpty(t *testing.T) { + cm := ConfigMap{} + assert.Equal(t, true, cm.Empty()) + cm.Root = &yaml.Node{ + Content: []*yaml.Node{ + { + Value: "test", + }, + }, + } + assert.Equal(t, false, cm.Empty()) +} + +func TestGetStringValue(t *testing.T) { + tests := []struct { + name string + key string + wantValue string + wantErr bool + }{ + { + name: "get key", + key: "valid", + wantValue: "present", + }, + { + name: "get key that is not present", + key: "invalid", + wantErr: true, + }, + { + name: "get key that has same content as a value", + key: "same", + wantValue: "logical", + }, + } + + for _, tt := range tests { + cm := ConfigMap{Root: testYaml()} + t.Run(tt.name, func(t *testing.T) { + val, err := cm.GetStringValue(tt.key) + if tt.wantErr { + assert.EqualError(t, err, "not found") + return + } + assert.Equal(t, tt.wantValue, val) + }) + } +} + +func TestSetStringValue(t *testing.T) { + tests := []struct { + name string + key string + value string + }{ + { + name: "set key that is not present", + key: "notPresent", + value: "test1", + }, + { + name: "set key that is present", + key: "erroneous", + value: "test2", + }, + { + name: "set key that is blank", + key: "blank", + value: "test3", + }, + { + name: "set key that has same content as a value", + key: "present", + value: "test4", + }, + } + + for _, tt := range tests { + cm := ConfigMap{Root: testYaml()} + t.Run(tt.name, func(t *testing.T) { + err := cm.SetStringValue(tt.key, tt.value) + assert.NoError(t, err) + val, err := cm.GetStringValue(tt.key) + assert.NoError(t, err) + assert.Equal(t, tt.value, val) + }) + } +} + +func TestRemoveEntry(t *testing.T) { + tests := []struct { + name string + key string + wantLength int + }{ + { + name: "remove key", + key: "erroneous", + wantLength: 6, + }, + { + name: "remove key that is not present", + key: "invalid", + wantLength: 8, + }, + { + name: "remove key that has same content as a value", + key: "same", + wantLength: 6, + }, + } + + for _, tt := range tests { + cm := ConfigMap{Root: testYaml()} + t.Run(tt.name, func(t *testing.T) { + cm.RemoveEntry(tt.key) + assert.Equal(t, tt.wantLength, len(cm.Root.Content)) + _, err := cm.FindEntry(tt.key) + assert.EqualError(t, err, "not found") + }) + } +} + func testYaml() *yaml.Node { var root yaml.Node var data = ` diff --git a/internal/config/config_type.go b/internal/config/config_type.go index 92792e93f..7a71e0c96 100644 --- a/internal/config/config_type.go +++ b/internal/config/config_type.go @@ -9,7 +9,10 @@ import ( // This interface describes interacting with some persistent configuration for gh. type Config interface { Get(string, string) (string, error) + GetOrDefault(string, string) (string, error) GetWithSource(string, string) (string, string, error) + GetOrDefaultWithSource(string, string) (string, string, error) + Default(string) string Set(string, string, string) error UnsetHost(string) Hosts() ([]string, error) diff --git a/internal/config/config_type_test.go b/internal/config/config_type_test.go index bf53aabe4..c16455bcc 100644 --- a/internal/config/config_type_test.go +++ b/internal/config/config_type_test.go @@ -58,7 +58,7 @@ func Test_defaultConfig(t *testing.T) { assert.Equal(t, expected, mainBuf.String()) assert.Equal(t, "", hostsBuf.String()) - proto, err := cfg.Get("", "git_protocol") + proto, err := cfg.GetOrDefault("", "git_protocol") assert.NoError(t, err) assert.Equal(t, "https", proto) diff --git a/internal/config/from_env.go b/internal/config/from_env.go index 6373f1691..27cf3c54b 100644 --- a/internal/config/from_env.go +++ b/internal/config/from_env.go @@ -3,6 +3,7 @@ package config import ( "fmt" "os" + "strconv" "github.com/cli/cli/v2/internal/ghinstance" ) @@ -13,6 +14,7 @@ const ( GITHUB_TOKEN = "GITHUB_TOKEN" GH_ENTERPRISE_TOKEN = "GH_ENTERPRISE_TOKEN" GITHUB_ENTERPRISE_TOKEN = "GITHUB_ENTERPRISE_TOKEN" + CODESPACES = "CODESPACES" ) type ReadOnlyEnvError struct { @@ -74,6 +76,24 @@ func (c *envConfig) GetWithSource(hostname, key string) (string, string, error) return c.Config.GetWithSource(hostname, key) } +func (c *envConfig) GetOrDefault(hostname, key string) (val string, err error) { + val, _, err = c.GetOrDefaultWithSource(hostname, key) + return +} + +func (c *envConfig) GetOrDefaultWithSource(hostname, key string) (val string, src string, err error) { + val, src, err = c.GetWithSource(hostname, key) + if err == nil && val == "" { + val = c.Default(key) + } + + return +} + +func (c *envConfig) Default(key string) string { + return c.Config.Default(key) +} + func (c *envConfig) CheckWriteable(hostname, key string) error { if hostname != "" && key == "oauth_token" { if token, env := AuthTokenFromEnv(hostname); token != "" { @@ -90,7 +110,15 @@ func AuthTokenFromEnv(hostname string) (string, string) { return token, GH_ENTERPRISE_TOKEN } - return os.Getenv(GITHUB_ENTERPRISE_TOKEN), GITHUB_ENTERPRISE_TOKEN + if token := os.Getenv(GITHUB_ENTERPRISE_TOKEN); token != "" { + return token, GITHUB_ENTERPRISE_TOKEN + } + + if isCodespaces, _ := strconv.ParseBool(os.Getenv(CODESPACES)); isCodespaces { + return os.Getenv(GITHUB_TOKEN), GITHUB_TOKEN + } + + return "", "" } if token := os.Getenv(GH_TOKEN); token != "" { diff --git a/internal/config/from_env_test.go b/internal/config/from_env_test.go index 765cd0160..bf81c7976 100644 --- a/internal/config/from_env_test.go +++ b/internal/config/from_env_test.go @@ -8,6 +8,18 @@ import ( "github.com/stretchr/testify/assert" ) +func setenv(t *testing.T, key, newValue string) { + oldValue, hasValue := os.LookupEnv(key) + os.Setenv(key, newValue) + t.Cleanup(func() { + if hasValue { + os.Setenv(key, oldValue) + } else { + os.Unsetenv(key) + } + }) +} + func TestInheritEnv(t *testing.T) { orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN") orig_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN") @@ -36,6 +48,7 @@ func TestInheritEnv(t *testing.T) { GITHUB_ENTERPRISE_TOKEN string GH_TOKEN string GH_ENTERPRISE_TOKEN string + CODESPACES string hostname string wants wants }{ @@ -98,6 +111,19 @@ func TestInheritEnv(t *testing.T) { writeable: true, }, }, + { + name: "GITHUB_TOKEN allowed in Codespaces", + baseConfig: ``, + GITHUB_TOKEN: "OTOKEN", + hostname: "example.org", + CODESPACES: "true", + wants: wants{ + hosts: []string{"github.com"}, + token: "OTOKEN", + source: "GITHUB_TOKEN", + writeable: false, + }, + }, { name: "GITHUB_ENTERPRISE_TOKEN over blank config", baseConfig: ``, @@ -262,11 +288,12 @@ func TestInheritEnv(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - os.Setenv("GITHUB_TOKEN", tt.GITHUB_TOKEN) - os.Setenv("GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN) - os.Setenv("GH_TOKEN", tt.GH_TOKEN) - os.Setenv("GH_ENTERPRISE_TOKEN", tt.GH_ENTERPRISE_TOKEN) - os.Setenv("AppData", "") + setenv(t, "GITHUB_TOKEN", tt.GITHUB_TOKEN) + setenv(t, "GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN) + setenv(t, "GH_TOKEN", tt.GH_TOKEN) + setenv(t, "GH_ENTERPRISE_TOKEN", tt.GH_ENTERPRISE_TOKEN) + setenv(t, "AppData", "") + setenv(t, "CODESPACES", tt.CODESPACES) baseCfg := NewFromString(tt.baseConfig) cfg := InheritEnv(baseCfg) diff --git a/internal/config/from_file.go b/internal/config/from_file.go index 080143df4..3c1cfd65b 100644 --- a/internal/config/from_file.go +++ b/internal/config/from_file.go @@ -65,13 +65,26 @@ func (c *fileConfig) GetWithSource(hostname, key string) (string, string, error) return "", defaultSource, err } - if value == "" { - return defaultFor(key), defaultSource, nil - } - return value, defaultSource, nil } +func (c *fileConfig) GetOrDefault(hostname, key string) (val string, err error) { + val, _, err = c.GetOrDefaultWithSource(hostname, key) + return +} + +func (c *fileConfig) GetOrDefaultWithSource(hostname, key string) (val string, src string, err error) { + val, src, err = c.GetWithSource(hostname, key) + if err != nil && val == "" { + val = c.Default(key) + } + return +} + +func (c *fileConfig) Default(key string) string { + return defaultFor(key) +} + func (c *fileConfig) Set(hostname, key, value string) error { if hostname == "" { return c.SetStringValue(key, value) diff --git a/internal/config/stub.go b/internal/config/stub.go index e68183d32..aeb2e5526 100644 --- a/internal/config/stub.go +++ b/internal/config/stub.go @@ -25,6 +25,23 @@ func (c ConfigStub) GetWithSource(host, key string) (string, string, error) { return "", "", errors.New("not found") } +func (c ConfigStub) GetOrDefault(hostname, key string) (val string, err error) { + val, _, err = c.GetOrDefaultWithSource(hostname, key) + return +} + +func (c ConfigStub) GetOrDefaultWithSource(hostname, key string) (val string, src string, err error) { + val, src, err = c.GetWithSource(hostname, key) + if err == nil && val == "" { + val = c.Default(key) + } + return +} + +func (c ConfigStub) Default(key string) string { + return defaultFor(key) +} + func (c ConfigStub) Set(host, key, value string) error { c[genKey(host, key)] = value return nil diff --git a/internal/docs/docs_test.go b/internal/docs/docs_test.go index e6b15062e..ad1b32631 100644 --- a/internal/docs/docs_test.go +++ b/internal/docs/docs_test.go @@ -79,12 +79,14 @@ var dummyCmd = &cobra.Command{ } func checkStringContains(t *testing.T, got, expected string) { + t.Helper() if !strings.Contains(got, expected) { t.Errorf("Expected to contain: \n %v\nGot:\n %v\n", expected, got) } } func checkStringOmits(t *testing.T, got, expected string) { + t.Helper() if strings.Contains(got, expected) { t.Errorf("Expected to not contain: \n %v\nGot: %v", expected, got) } diff --git a/internal/docs/man.go b/internal/docs/man.go index 570e9c780..6a259a0bd 100644 --- a/internal/docs/man.go +++ b/internal/docs/man.go @@ -6,7 +6,6 @@ import ( "io" "os" "path/filepath" - "sort" "strconv" "strings" "time" @@ -21,9 +20,8 @@ import ( // correctly if your command names have `-` in them. If you have `cmd` with two // subcmds, `sub` and `sub-third`, and `sub` has a subcommand called `third` // it is undefined which help output will be in the file `cmd-sub-third.1`. -func GenManTree(cmd *cobra.Command, header *GenManHeader, dir string) error { +func GenManTree(cmd *cobra.Command, dir string) error { return GenManTreeFromOpts(cmd, GenManTreeOptions{ - Header: header, Path: dir, CommandSeparator: "-", }) @@ -32,10 +30,6 @@ func GenManTree(cmd *cobra.Command, header *GenManHeader, dir string) error { // GenManTreeFromOpts generates a man page for the command and all descendants. // The pages are written to the opts.Path directory. func GenManTreeFromOpts(cmd *cobra.Command, opts GenManTreeOptions) error { - header := opts.Header - if header == nil { - header = &GenManHeader{} - } for _, c := range cmd.Commands() { if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() { continue @@ -44,11 +38,8 @@ func GenManTreeFromOpts(cmd *cobra.Command, opts GenManTreeOptions) error { return err } } - section := "1" - if header.Section != "" { - section = header.Section - } + section := "1" separator := "_" if opts.CommandSeparator != "" { separator = opts.CommandSeparator @@ -61,14 +52,21 @@ func GenManTreeFromOpts(cmd *cobra.Command, opts GenManTreeOptions) error { } defer f.Close() - headerCopy := *header - return GenMan(cmd, &headerCopy, f) + var versionString string + if v := os.Getenv("GH_VERSION"); v != "" { + versionString = "GitHub CLI " + v + } + + return GenMan(cmd, &GenManHeader{ + Section: section, + Source: versionString, + Manual: "GitHub CLI manual", + }, f) } // GenManTreeOptions is the options for generating the man pages. // Used only in GenManTreeFromOpts. type GenManTreeOptions struct { - Header *GenManHeader Path string CommandSeparator string } @@ -80,7 +78,6 @@ type GenManHeader struct { Title string Section string Date *time.Time - date string Source string Manual string } @@ -88,14 +85,12 @@ type GenManHeader struct { // GenMan will generate a man page for the given command and write it to // w. The header argument may be nil, however obviously w may not. func GenMan(cmd *cobra.Command, header *GenManHeader, w io.Writer) error { - if header == nil { - header = &GenManHeader{} - } if err := fillHeader(header, cmd.CommandPath()); err != nil { return err } b := genMan(cmd, header) + _, err := w.Write(md2man.Render(b)) return err } @@ -118,51 +113,40 @@ func fillHeader(header *GenManHeader, name string) error { } header.Date = &now } - header.date = (*header.Date).Format("Jan 2006") return nil } func manPreamble(buf *bytes.Buffer, header *GenManHeader, cmd *cobra.Command, dashedName string) { - description := cmd.Long - if len(description) == 0 { - description = cmd.Short - } - buf.WriteString(fmt.Sprintf(`%% "%s" "%s" "%s" "%s" "%s" # NAME -`, header.Title, header.Section, header.date, header.Source, header.Manual)) +`, header.Title, header.Section, header.Date.Format("Jan 2006"), header.Source, header.Manual)) buf.WriteString(fmt.Sprintf("%s \\- %s\n\n", dashedName, cmd.Short)) buf.WriteString("# SYNOPSIS\n") - buf.WriteString(fmt.Sprintf("**%s**\n\n", cmd.UseLine())) - buf.WriteString("# DESCRIPTION\n") - buf.WriteString(description + "\n\n") + buf.WriteString(fmt.Sprintf("`%s`\n\n", cmd.UseLine())) + + if cmd.Long != "" && cmd.Long != cmd.Short { + buf.WriteString("# DESCRIPTION\n") + buf.WriteString(cmd.Long + "\n\n") + } } func manPrintFlags(buf *bytes.Buffer, flags *pflag.FlagSet) { flags.VisitAll(func(flag *pflag.Flag) { - if len(flag.Deprecated) > 0 || flag.Hidden { + if len(flag.Deprecated) > 0 || flag.Hidden || flag.Name == "help" { return } - format := "" + varname, usage := pflag.UnquoteUsage(flag) if len(flag.Shorthand) > 0 && len(flag.ShorthandDeprecated) == 0 { - format = fmt.Sprintf("**-%s**, **--%s**", flag.Shorthand, flag.Name) + buf.WriteString(fmt.Sprintf("`-%s`, `--%s`", flag.Shorthand, flag.Name)) } else { - format = fmt.Sprintf("**--%s**", flag.Name) + buf.WriteString(fmt.Sprintf("`--%s`", flag.Name)) } - if len(flag.NoOptDefVal) > 0 { - format += "[" - } - if flag.Value.Type() == "string" { - // put quotes on the value - format += "=%q" + if varname == "" { + buf.WriteString("\n") } else { - format += "=%s" + buf.WriteString(fmt.Sprintf(" `<%s>`\n", varname)) } - if len(flag.NoOptDefVal) > 0 { - format += "]" - } - format += "\n\t%s\n\n" - buf.WriteString(fmt.Sprintf(format, flag.DefValue, flag.Usage)) + buf.WriteString(fmt.Sprintf(": %s\n\n", usage)) }) } @@ -174,7 +158,7 @@ func manPrintOptions(buf *bytes.Buffer, command *cobra.Command) { buf.WriteString("\n") } flags = command.InheritedFlags() - if flags.HasAvailableFlags() { + if hasNonHelpFlags(flags) { buf.WriteString("# OPTIONS INHERITED FROM PARENT COMMANDS\n") manPrintFlags(buf, flags) buf.WriteString("\n") @@ -191,52 +175,28 @@ func genMan(cmd *cobra.Command, header *GenManHeader) []byte { buf := new(bytes.Buffer) manPreamble(buf, header, cmd, dashCommandName) + for _, g := range subcommandGroups(cmd) { + if len(g.Commands) == 0 { + continue + } + fmt.Fprintf(buf, "# %s\n", strings.ToUpper(g.Name)) + for _, subcmd := range g.Commands { + fmt.Fprintf(buf, "`%s`\n: %s\n\n", manLink(subcmd), subcmd.Short) + } + } manPrintOptions(buf, cmd) if len(cmd.Example) > 0 { buf.WriteString("# EXAMPLE\n") buf.WriteString(fmt.Sprintf("```\n%s\n```\n", cmd.Example)) } - if hasSeeAlso(cmd) { + if cmd.HasParent() { buf.WriteString("# SEE ALSO\n") - seealsos := make([]string, 0) - if cmd.HasParent() { - parentPath := cmd.Parent().CommandPath() - dashParentPath := strings.Replace(parentPath, " ", "-", -1) - seealso := fmt.Sprintf("**%s(%s)**", dashParentPath, header.Section) - seealsos = append(seealsos, seealso) - } - children := cmd.Commands() - sort.Sort(byName(children)) - for _, c := range children { - if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() { - continue - } - seealso := fmt.Sprintf("**%s-%s(%s)**", dashCommandName, c.Name(), header.Section) - seealsos = append(seealsos, seealso) - } - buf.WriteString(strings.Join(seealsos, ", ") + "\n") + buf.WriteString(fmt.Sprintf("`%s`\n", manLink(cmd.Parent()))) } return buf.Bytes() } -// Test to see if we have a reason to print See Also information in docs -// Basically this is a test for a parent command or a subcommand which is -// both not deprecated and not the autogenerated help command. -func hasSeeAlso(cmd *cobra.Command) bool { - if cmd.HasParent() { - return true - } - for _, c := range cmd.Commands() { - if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() { - continue - } - return true - } - return false +func manLink(cmd *cobra.Command) string { + p := cmd.CommandPath() + return fmt.Sprintf("%s(%d)", strings.Replace(p, " ", "-", -1), 1) } - -type byName []*cobra.Command - -func (s byName) Len() int { return len(s) } -func (s byName) Swap(i, j int) { s[i], s[j] = s[j], s[i] } -func (s byName) Less(i, j int) bool { return s[i].Name() < s[j].Name() } diff --git a/internal/docs/man_test.go b/internal/docs/man_test.go index d72ea7214..daf54008f 100644 --- a/internal/docs/man_test.go +++ b/internal/docs/man_test.go @@ -20,7 +20,7 @@ func translate(in string) string { func TestGenManDoc(t *testing.T) { header := &GenManHeader{ Title: "Project", - Section: "2", + Section: "1", } // We generate on a subcommand so we have both subcommands and parents @@ -49,7 +49,7 @@ func TestGenManDoc(t *testing.T) { func TestGenManNoHiddenParents(t *testing.T) { header := &GenManHeader{ Title: "Project", - Section: "2", + Section: "1", } // We generate on a subcommand so we have both subcommands and parents @@ -94,15 +94,8 @@ func TestGenManSeeAlso(t *testing.T) { t.Fatal(err) } scanner := bufio.NewScanner(buf) - - if err := assertLineFound(scanner, ".SH SEE ALSO"); err != nil { - t.Fatalf("Couldn't find SEE ALSO section header: %v", err) - } - if err := assertNextLineEquals(scanner, ".PP"); err != nil { - t.Fatalf("First line after SEE ALSO wasn't break-indent: %v", err) - } - if err := assertNextLineEquals(scanner, `\fBroot\-bbb(1)\fP, \fBroot\-ccc(1)\fP`); err != nil { - t.Fatalf("Second line after SEE ALSO wasn't correct: %v", err) + if err := assertLineFound(scanner, ".SH SEE ALSO"); err == nil { + t.Fatalf("Did not expect SEE ALSO section header") } } @@ -115,31 +108,26 @@ func TestManPrintFlagsHidesShortDeprecated(t *testing.T) { manPrintFlags(buf, c.Flags()) got := buf.String() - expected := "**--foo**=\"default\"\n\tFoo flag\n\n" + expected := "`--foo` ``\n: Foo flag\n\n" if got != expected { - t.Errorf("Expected %v, got %v", expected, got) + t.Errorf("Expected %q, got %q", expected, got) } } func TestGenManTree(t *testing.T) { c := &cobra.Command{Use: "do [OPTIONS] arg1 arg2"} - header := &GenManHeader{Section: "2"} tmpdir, err := ioutil.TempDir("", "test-gen-man-tree") if err != nil { t.Fatalf("Failed to create tmpdir: %s", err.Error()) } defer os.RemoveAll(tmpdir) - if err := GenManTree(c, header, tmpdir); err != nil { + if err := GenManTree(c, tmpdir); err != nil { t.Fatalf("GenManTree failed: %s", err.Error()) } - if _, err := os.Stat(filepath.Join(tmpdir, "do.2")); err != nil { - t.Fatalf("Expected file 'do.2' to exist") - } - - if header.Title != "" { - t.Fatalf("Expected header.Title to be unmodified") + if _, err := os.Stat(filepath.Join(tmpdir, "do.1")); err != nil { + t.Fatalf("Expected file 'do.1' to exist") } } @@ -158,22 +146,6 @@ func assertLineFound(scanner *bufio.Scanner, expectedLine string) error { return fmt.Errorf("hit EOF before finding %v", expectedLine) } -func assertNextLineEquals(scanner *bufio.Scanner, expectedLine string) error { - if scanner.Scan() { - line := scanner.Text() - if line == expectedLine { - return nil - } - return fmt.Errorf("got %v, not %v", line, expectedLine) - } - - if err := scanner.Err(); err != nil { - return fmt.Errorf("scan failed: %v", err) - } - - return fmt.Errorf("hit EOF before finding %v", expectedLine) -} - func BenchmarkGenManToFile(b *testing.B) { file, err := ioutil.TempFile(b.TempDir(), "") if err != nil { diff --git a/internal/docs/markdown.go b/internal/docs/markdown.go index 3432e9784..fc98b2810 100644 --- a/internal/docs/markdown.go +++ b/internal/docs/markdown.go @@ -1,35 +1,83 @@ package docs import ( - "bytes" "fmt" + "html/template" "io" "os" "path/filepath" "strings" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) -func printOptions(buf *bytes.Buffer, cmd *cobra.Command, name string) error { +func printOptions(w io.Writer, cmd *cobra.Command) error { flags := cmd.NonInheritedFlags() - flags.SetOutput(buf) + flags.SetOutput(w) if flags.HasAvailableFlags() { - buf.WriteString("### Options\n\n```\n") - flags.PrintDefaults() - buf.WriteString("```\n\n") + fmt.Fprint(w, "### Options\n\n") + if err := printFlagsHTML(w, flags); err != nil { + return err + } + fmt.Fprint(w, "\n\n") } parentFlags := cmd.InheritedFlags() - parentFlags.SetOutput(buf) - if parentFlags.HasAvailableFlags() { - buf.WriteString("### Options inherited from parent commands\n\n```\n") - parentFlags.PrintDefaults() - buf.WriteString("```\n\n") + parentFlags.SetOutput(w) + if hasNonHelpFlags(parentFlags) { + fmt.Fprint(w, "### Options inherited from parent commands\n\n") + if err := printFlagsHTML(w, parentFlags); err != nil { + return err + } + fmt.Fprint(w, "\n\n") } return nil } +func hasNonHelpFlags(fs *pflag.FlagSet) (found bool) { + fs.VisitAll(func(f *pflag.Flag) { + if !f.Hidden && f.Name != "help" { + found = true + } + }) + return +} + +type flagView struct { + Name string + Varname string + Shorthand string + Usage string +} + +var flagsTemplate = ` +
{{ range . }} +
{{ if .Shorthand }}-{{.Shorthand}}, {{ end -}} + --{{.Name}}{{ if .Varname }} <{{.Varname}}>{{ end }}
+
{{.Usage}}
+{{ end }}
+` + +var tpl = template.Must(template.New("flags").Parse(flagsTemplate)) + +func printFlagsHTML(w io.Writer, fs *pflag.FlagSet) error { + var flags []flagView + fs.VisitAll(func(f *pflag.Flag) { + if f.Hidden || f.Name == "help" { + return + } + varname, usage := pflag.UnquoteUsage(f) + flags = append(flags, flagView{ + Name: f.Name, + Varname: varname, + Shorthand: f.Shorthand, + Usage: usage, + }) + }) + return tpl.Execute(w, flags) +} + // GenMarkdown creates markdown output. func GenMarkdown(cmd *cobra.Command, w io.Writer) error { return GenMarkdownCustom(cmd, w, func(s string) string { return s }) @@ -37,33 +85,97 @@ func GenMarkdown(cmd *cobra.Command, w io.Writer) error { // GenMarkdownCustom creates custom markdown output. func GenMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string) string) error { - cmd.InitDefaultHelpCmd() - cmd.InitDefaultHelpFlag() + fmt.Fprintf(w, "## %s\n\n", cmd.CommandPath()) - buf := new(bytes.Buffer) - name := cmd.CommandPath() - - buf.WriteString("## " + name + "\n\n") - buf.WriteString(cmd.Short + "\n\n") - if len(cmd.Long) > 0 { - buf.WriteString("### Synopsis\n\n") - buf.WriteString(cmd.Long + "\n\n") + hasLong := cmd.Long != "" + if !hasLong { + fmt.Fprintf(w, "%s\n\n", cmd.Short) + } + if cmd.Runnable() { + fmt.Fprintf(w, "```\n%s\n```\n\n", cmd.UseLine()) + } + if hasLong { + fmt.Fprintf(w, "%s\n\n", cmd.Long) } - if cmd.Runnable() { - buf.WriteString(fmt.Sprintf("```\n%s\n```\n\n", cmd.UseLine())) + for _, g := range subcommandGroups(cmd) { + if len(g.Commands) == 0 { + continue + } + fmt.Fprintf(w, "### %s\n\n", g.Name) + for _, subcmd := range g.Commands { + fmt.Fprintf(w, "* [%s](%s)\n", subcmd.CommandPath(), linkHandler(cmdManualPath(subcmd))) + } + fmt.Fprint(w, "\n\n") + } + + if err := printOptions(w, cmd); err != nil { + return err } if len(cmd.Example) > 0 { - buf.WriteString("### Examples\n\n") - buf.WriteString(fmt.Sprintf("```\n%s\n```\n\n", cmd.Example)) + fmt.Fprint(w, "### Examples\n\n{% highlight bash %}{% raw %}\n") + fmt.Fprint(w, cmd.Example) + fmt.Fprint(w, "{% endraw %}{% endhighlight %}\n\n") } - if err := printOptions(buf, cmd, name); err != nil { - return err + if cmd.HasParent() { + p := cmd.Parent() + fmt.Fprint(w, "### See also\n\n") + fmt.Fprintf(w, "* [%s](%s)\n", p.CommandPath(), linkHandler(cmdManualPath(p))) + } + + return nil +} + +type commandGroup struct { + Name string + Commands []*cobra.Command +} + +// subcommandGroups lists child commands of a Cobra command split into groups. +// TODO: have rootHelpFunc use this instead of repeating the same logic. +func subcommandGroups(c *cobra.Command) []commandGroup { + var rest []*cobra.Command + var core []*cobra.Command + var actions []*cobra.Command + + for _, subcmd := range c.Commands() { + if !subcmd.IsAvailableCommand() { + continue + } + if _, ok := subcmd.Annotations["IsCore"]; ok { + core = append(core, subcmd) + } else if _, ok := subcmd.Annotations["IsActions"]; ok { + actions = append(actions, subcmd) + } else { + rest = append(rest, subcmd) + } + } + + if len(core) > 0 { + return []commandGroup{ + { + Name: "Core commands", + Commands: core, + }, + { + Name: "Actions commands", + Commands: actions, + }, + { + Name: "Additional commands", + Commands: rest, + }, + } + } + + return []commandGroup{ + { + Name: "Commands", + Commands: rest, + }, } - _, err := buf.WriteTo(w) - return err } // GenMarkdownTree will generate a markdown page for this command and all @@ -92,12 +204,7 @@ func GenMarkdownTreeCustom(cmd *cobra.Command, dir string, filePrepender, linkHa } } - basename := strings.Replace(cmd.CommandPath(), " ", "_", -1) + ".md" - if basenameOverride, found := cmd.Annotations["markdown:basename"]; found { - basename = basenameOverride + ".md" - } - - filename := filepath.Join(dir, basename) + filename := filepath.Join(dir, cmdManualPath(cmd)) f, err := os.Create(filename) if err != nil { return err @@ -112,3 +219,10 @@ func GenMarkdownTreeCustom(cmd *cobra.Command, dir string, filePrepender, linkHa } return nil } + +func cmdManualPath(c *cobra.Command) string { + if basenameOverride, found := c.Annotations["markdown:basename"]; found { + return basenameOverride + ".md" + } + return strings.ReplaceAll(c.CommandPath(), " ", "_") + ".md" +} diff --git a/internal/ghinstance/host.go b/internal/ghinstance/host.go index 709d71255..b96852a4d 100644 --- a/internal/ghinstance/host.go +++ b/internal/ghinstance/host.go @@ -8,6 +8,9 @@ import ( const defaultHostname = "github.com" +// localhost is the domain name of a local GitHub instance +const localhost = "github.localhost" + // Default returns the host name of the default GitHub instance func Default() string { return defaultHostname @@ -15,7 +18,8 @@ func Default() string { // IsEnterprise reports whether a non-normalized host name looks like a GHE instance func IsEnterprise(h string) bool { - return NormalizeHostname(h) != defaultHostname + normalizedHostName := NormalizeHostname(h) + return normalizedHostName != defaultHostname && normalizedHostName != localhost } // NormalizeHostname returns the canonical host name of a GitHub instance @@ -24,6 +28,11 @@ func NormalizeHostname(h string) string { if strings.HasSuffix(hostname, "."+defaultHostname) { return defaultHostname } + + if strings.HasSuffix(hostname, "."+localhost) { + return localhost + } + return hostname } @@ -46,19 +55,45 @@ func GraphQLEndpoint(hostname string) string { if IsEnterprise(hostname) { return fmt.Sprintf("https://%s/api/graphql", hostname) } - return "https://api.github.com/graphql" + if strings.EqualFold(hostname, localhost) { + return fmt.Sprintf("http://api.%s/graphql", hostname) + } + return fmt.Sprintf("https://api.%s/graphql", hostname) } func RESTPrefix(hostname string) string { if IsEnterprise(hostname) { return fmt.Sprintf("https://%s/api/v3/", hostname) } - return "https://api.github.com/" + if strings.EqualFold(hostname, localhost) { + return fmt.Sprintf("http://api.%s/", hostname) + } + return fmt.Sprintf("https://api.%s/", hostname) } func GistPrefix(hostname string) string { - if IsEnterprise(hostname) { - return fmt.Sprintf("https://%s/gist/", hostname) + prefix := "https://" + + if strings.EqualFold(hostname, localhost) { + prefix = "http://" } - return fmt.Sprintf("https://gist.%s/", hostname) + + return prefix + GistHost(hostname) +} + +func GistHost(hostname string) string { + if IsEnterprise(hostname) { + return fmt.Sprintf("%s/gist/", hostname) + } + if strings.EqualFold(hostname, localhost) { + return fmt.Sprintf("%s/gist/", hostname) + } + return fmt.Sprintf("gist.%s/", hostname) +} + +func HostPrefix(hostname string) string { + if strings.EqualFold(hostname, localhost) { + return fmt.Sprintf("http://%s/", hostname) + } + return fmt.Sprintf("https://%s/", hostname) } diff --git a/internal/ghinstance/host_test.go b/internal/ghinstance/host_test.go index 45bac3800..d29cd45ea 100644 --- a/internal/ghinstance/host_test.go +++ b/internal/ghinstance/host_test.go @@ -19,6 +19,14 @@ func TestIsEnterprise(t *testing.T) { host: "api.github.com", want: false, }, + { + host: "github.localhost", + want: false, + }, + { + host: "api.github.localhost", + want: false, + }, { host: "ghe.io", want: true, @@ -58,6 +66,14 @@ func TestNormalizeHostname(t *testing.T) { host: "upload.github.com", want: "github.com", }, + { + host: "GitHub.localhost", + want: "github.localhost", + }, + { + host: "api.github.localhost", + want: "github.localhost", + }, { host: "GHE.IO", want: "ghe.io", @@ -129,6 +145,10 @@ func TestGraphQLEndpoint(t *testing.T) { host: "github.com", want: "https://api.github.com/graphql", }, + { + host: "github.localhost", + want: "http://api.github.localhost/graphql", + }, { host: "ghe.io", want: "https://ghe.io/api/graphql", @@ -152,6 +172,10 @@ func TestRESTPrefix(t *testing.T) { host: "github.com", want: "https://api.github.com/", }, + { + host: "github.localhost", + want: "http://api.github.localhost/", + }, { host: "ghe.io", want: "https://ghe.io/api/v3/", diff --git a/internal/ghrepo/repo.go b/internal/ghrepo/repo.go index 77ed0b140..cb969ef94 100644 --- a/internal/ghrepo/repo.go +++ b/internal/ghrepo/repo.go @@ -53,6 +53,12 @@ func SetDefaultHost(host string) { // FromFullName extracts the GitHub repository information from the following // formats: "OWNER/REPO", "HOST/OWNER/REPO", and a full URL. func FromFullName(nwo string) (Interface, error) { + return FromFullNameWithHost(nwo, defaultHost()) +} + +// FromFullNameWithHost is like FromFullName that defaults to a specific host for values that don't +// explicitly include a hostname. +func FromFullNameWithHost(nwo, fallbackHost string) (Interface, error) { if git.IsURL(nwo) { u, err := git.ParseURL(nwo) if err != nil { @@ -71,7 +77,7 @@ func FromFullName(nwo string) (Interface, error) { case 3: return NewWithHost(parts[1], parts[2], parts[0]), nil case 2: - return NewWithHost(parts[0], parts[1], defaultHost()), nil + return NewWithHost(parts[0], parts[1], fallbackHost), nil default: return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo) } @@ -103,9 +109,11 @@ func IsSame(a, b Interface) bool { } func GenerateRepoURL(repo Interface, p string, args ...interface{}) string { - baseURL := fmt.Sprintf("https://%s/%s/%s", repo.RepoHost(), repo.RepoOwner(), repo.RepoName()) + baseURL := fmt.Sprintf("%s%s/%s", ghinstance.HostPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName()) if p != "" { - return baseURL + "/" + fmt.Sprintf(p, args...) + if path := fmt.Sprintf(p, args...); path != "" { + return baseURL + "/" + path + } } return baseURL } @@ -116,7 +124,7 @@ func FormatRemoteURL(repo Interface, protocol string) string { return fmt.Sprintf("git@%s:%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName()) } - return fmt.Sprintf("https://%s/%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName()) + return fmt.Sprintf("%s%s/%s.git", ghinstance.HostPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName()) } type ghRepo struct { diff --git a/pkg/cmd/actions/actions.go b/pkg/cmd/actions/actions.go index 1ce0dc233..e5620c42e 100644 --- a/pkg/cmd/actions/actions.go +++ b/pkg/cmd/actions/actions.go @@ -1,8 +1,6 @@ package actions import ( - "fmt" - "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -14,11 +12,8 @@ func NewCmdActions(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "actions", - Short: "Learn about working with GitHub actions", + Short: "Learn about working with GitHub Actions", Long: actionsExplainer(cs), - Run: func(cmd *cobra.Command, args []string) { - fmt.Fprintln(f.IOStreams.Out, actionsExplainer(cs)) - }, Annotations: map[string]string{ "IsActions": "true", }, diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index d68b0d773..29b29532f 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -21,6 +21,7 @@ import ( "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/export" "github.com/cli/cli/v2/pkg/iostreams" @@ -71,21 +72,23 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command The endpoint argument should either be a path of a GitHub API v3 endpoint, or "graphql" to access the GitHub API v4. - Placeholder values "{owner}", "{repo}", and "{branch}" in the endpoint argument will - get replaced with values from the repository of the current directory. Note that in - some shells, for example PowerShell, you may need to enclose any value that contains - "{...}" in quotes to prevent the shell from applying special meaning to curly braces. + Placeholder values "{owner}", "{repo}", and "{branch}" in the endpoint + argument will get replaced with values from the repository of the current + directory or the repository specified in the GH_REPO environment variable. + Note that in some shells, for example PowerShell, you may need to enclose + any value that contains "{...}" in quotes to prevent the shell from + applying special meaning to curly braces. The default HTTP request method is "GET" normally and "POST" if any parameters were added. Override the method with %[1]s--method%[1]s. - Pass one or more %[1]s--raw-field%[1]s values in "key=value" format to add string - parameters to the request payload. To add non-string parameters, see %[1]s--field%[1]s below. - Note that adding request parameters will automatically switch the request method to POST. - To send the parameters as a GET query string instead, use %[1]s--method%[1]s GET. + Pass one or more %[1]s-f/--raw-field%[1]s values in "key=value" format to add static string + parameters to the request payload. To add non-string or otherwise dynamic values, see + %[1]s--field%[1]s below. Note that adding request parameters will automatically switch the + request method to POST. To send the parameters as a GET query string instead, use + %[1]s--method GET%[1]s. - The %[1]s--field%[1]s flag behaves like %[1]s--raw-field%[1]s with magic type conversion based - on the format of the value: + The %[1]s-F/--field%[1]s flag has magic type conversion based on the format of the value: - literal values "true", "false", "null", and integer numbers get converted to appropriate JSON types; @@ -167,18 +170,21 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command `), }, Args: cobra.ExactArgs(1), + PreRun: func(c *cobra.Command, args []string) { + opts.BaseRepo = cmdutil.OverrideBaseRepoFunc(f, "") + }, RunE: func(c *cobra.Command, args []string) error { opts.RequestPath = args[0] opts.RequestMethodPassed = c.Flags().Changed("method") if c.Flags().Changed("hostname") { if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { - return &cmdutil.FlagError{Err: fmt.Errorf("error parsing `--hostname`: %w", err)} + return cmdutil.FlagErrorf("error parsing `--hostname`: %w", err) } } if opts.Paginate && !strings.EqualFold(opts.RequestMethod, "GET") && opts.RequestPath != "graphql" { - return &cmdutil.FlagError{Err: errors.New("the `--paginate` option is not supported for non-GET requests")} + return cmdutil.FlagErrorf("the `--paginate` option is not supported for non-GET requests") } if err := cmdutil.MutuallyExclusive( @@ -213,7 +219,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command cmd.Flags().StringSliceVarP(&opts.Previews, "preview", "p", nil, "Opt into GitHub API previews") cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output") cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results") - cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request") + cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request (use \"-\" to read from standard input)") cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Do not print the response body") cmd.Flags().StringVarP(&opts.Template, "template", "t", "", "Format the response using a Go template") cmd.Flags().StringVarP(&opts.FilterOutput, "jq", "q", "", "Query to select values from the response using jq syntax") @@ -319,6 +325,7 @@ func apiRun(opts *ApiOptions) error { } } else { requestPath, hasNextPage = findNextPage(resp) + requestBody = nil // prevent repeating GET parameters } if hasNextPage && opts.ShowResponseHeaders { @@ -384,12 +391,17 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream } } + if serverError == "" && resp.StatusCode > 299 { + serverError = fmt.Sprintf("HTTP %d", resp.StatusCode) + } if serverError != "" { fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", serverError) - err = cmdutil.SilentError - return - } else if resp.StatusCode > 299 { - fmt.Fprintf(opts.IO.ErrOut, "gh: HTTP %d\n", resp.StatusCode) + if msg := api.ScopesSuggestion(resp); msg != "" { + fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", msg) + } + if u := factory.SSOURL(); u != "" { + fmt.Fprintf(opts.IO.ErrOut, "Authorize in your web browser: %s\n", u) + } err = cmdutil.SilentError return } @@ -537,36 +549,58 @@ func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error) var parsedBody struct { Message string - Errors []json.RawMessage + Errors json.RawMessage } err = json.Unmarshal(b, &parsedBody) if err != nil { - return r, "", err + return bodyCopy, "", err } + + if len(parsedBody.Errors) > 0 && parsedBody.Errors[0] == '"' { + var stringError string + if err := json.Unmarshal(parsedBody.Errors, &stringError); err != nil { + return bodyCopy, "", err + } + if stringError != "" { + if parsedBody.Message != "" { + return bodyCopy, fmt.Sprintf("%s (%s)", stringError, parsedBody.Message), nil + } + return bodyCopy, stringError, nil + } + } + if parsedBody.Message != "" { return bodyCopy, fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil } - type errorMessage struct { + if len(parsedBody.Errors) == 0 || parsedBody.Errors[0] != '[' { + return bodyCopy, "", nil + } + + var errorObjects []json.RawMessage + if err := json.Unmarshal(parsedBody.Errors, &errorObjects); err != nil { + return bodyCopy, "", err + } + + var objectError struct { Message string } var errors []string - for _, rawErr := range parsedBody.Errors { + for _, rawErr := range errorObjects { if len(rawErr) == 0 { continue } if rawErr[0] == '{' { - var objectError errorMessage err := json.Unmarshal(rawErr, &objectError) if err != nil { - return r, "", err + return bodyCopy, "", err } errors = append(errors, objectError.Message) } else if rawErr[0] == '"' { var stringError string err := json.Unmarshal(rawErr, &stringError) if err != nil { - return r, "", err + return bodyCopy, "", err } errors = append(errors, stringError) } diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index daed26926..04da8cc29 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -577,8 +577,11 @@ func Test_apiRun_paginationREST(t *testing.T) { return config.NewBlankConfig(), nil }, - RequestPath: "issues", - Paginate: true, + RequestMethod: "GET", + RequestMethodPassed: true, + RequestPath: "issues", + Paginate: true, + RawFields: []string{"per_page=50", "page=1"}, } err := apiRun(&options) @@ -587,7 +590,7 @@ func Test_apiRun_paginationREST(t *testing.T) { assert.Equal(t, `{"page":1}{"page":2}{"page":3}`, stdout.String(), "stdout") assert.Equal(t, "", stderr.String(), "stderr") - assert.Equal(t, "https://api.github.com/issues?per_page=100", responses[0].Request.URL.String()) + assert.Equal(t, "https://api.github.com/issues?page=1&per_page=50", responses[0].Request.URL.String()) assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=2", responses[1].Request.URL.String()) assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=3", responses[2].Request.URL.String()) } @@ -1281,3 +1284,85 @@ func Test_processResponse_template(t *testing.T) { `), stdout.String()) assert.Equal(t, "", stderr.String()) } + +func Test_parseErrorResponse(t *testing.T) { + type args struct { + input string + statusCode int + } + tests := []struct { + name string + args args + wantErrMsg string + wantErr bool + }{ + { + name: "no error", + args: args{ + input: `{}`, + statusCode: 500, + }, + wantErrMsg: "", + wantErr: false, + }, + { + name: "nil errors", + args: args{ + input: `{"errors":null}`, + statusCode: 500, + }, + wantErrMsg: "", + wantErr: false, + }, + { + name: "simple error", + args: args{ + input: `{"message": "OH NOES"}`, + statusCode: 500, + }, + wantErrMsg: "OH NOES (HTTP 500)", + wantErr: false, + }, + { + name: "errors string", + args: args{ + input: `{"message": "Conflict", "errors": "Some description"}`, + statusCode: 409, + }, + wantErrMsg: "Some description (Conflict)", + wantErr: false, + }, + { + name: "errors array of strings", + args: args{ + input: `{"errors": ["fail1", "asplode2"]}`, + statusCode: 500, + }, + wantErrMsg: "fail1\nasplode2", + wantErr: false, + }, + { + name: "errors array of objects", + args: args{ + input: `{"errors": [{"message":"fail1"}, {"message":"asplode2"}]}`, + statusCode: 500, + }, + wantErrMsg: "fail1\nasplode2", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1, err := parseErrorResponse(strings.NewReader(tt.args.input), tt.args.statusCode) + if (err != nil) != tt.wantErr { + t.Errorf("parseErrorResponse() error = %v, wantErr %v", err, tt.wantErr) + } + if gotString, _ := ioutil.ReadAll(got); tt.args.input != string(gotString) { + t.Errorf("parseErrorResponse() got = %q, want %q", string(gotString), tt.args.input) + } + if got1 != tt.wantErrMsg { + t.Errorf("parseErrorResponse() got1 = %q, want %q", got1, tt.wantErrMsg) + } + }) + } +} diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go index 99d335a8d..e5ae67fa2 100644 --- a/pkg/cmd/auth/auth.go +++ b/pkg/cmd/auth/auth.go @@ -5,6 +5,7 @@ import ( authLoginCmd "github.com/cli/cli/v2/pkg/cmd/auth/login" authLogoutCmd "github.com/cli/cli/v2/pkg/cmd/auth/logout" authRefreshCmd "github.com/cli/cli/v2/pkg/cmd/auth/refresh" + authSetupGitCmd "github.com/cli/cli/v2/pkg/cmd/auth/setupgit" authStatusCmd "github.com/cli/cli/v2/pkg/cmd/auth/status" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -24,6 +25,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(authStatusCmd.NewCmdStatus(f, nil)) cmd.AddCommand(authRefreshCmd.NewCmdRefresh(f, nil)) cmd.AddCommand(gitCredentialCmd.NewCmdCredential(f, nil)) + cmd.AddCommand(authSetupGitCmd.NewCmdSetupGit(f, nil)) return cmd } diff --git a/pkg/cmd/auth/gitcredential/helper.go b/pkg/cmd/auth/gitcredential/helper.go index 8d1ab7ff3..117584b34 100644 --- a/pkg/cmd/auth/gitcredential/helper.go +++ b/pkg/cmd/auth/gitcredential/helper.go @@ -100,12 +100,18 @@ func helperRun(opts *CredentialOptions) error { return err } + lookupHost := wants["host"] var gotUser string - gotToken, source, _ := cfg.GetWithSource(wants["host"], "oauth_token") + gotToken, source, _ := cfg.GetWithSource(lookupHost, "oauth_token") + if gotToken == "" && strings.HasPrefix(lookupHost, "gist.") { + lookupHost = strings.TrimPrefix(lookupHost, "gist.") + gotToken, source, _ = cfg.GetWithSource(lookupHost, "oauth_token") + } + if strings.HasSuffix(source, "_TOKEN") { gotUser = tokenUser } else { - gotUser, _, _ = cfg.GetWithSource(wants["host"], "user") + gotUser, _, _ = cfg.GetWithSource(lookupHost, "user") } if gotUser == "" || gotToken == "" { diff --git a/pkg/cmd/auth/gitcredential/helper_test.go b/pkg/cmd/auth/gitcredential/helper_test.go index 7e30ec495..92cdcb692 100644 --- a/pkg/cmd/auth/gitcredential/helper_test.go +++ b/pkg/cmd/auth/gitcredential/helper_test.go @@ -8,12 +8,18 @@ import ( "github.com/cli/cli/v2/pkg/iostreams" ) +// why not just use the config stub argh type tinyConfig map[string]string func (c tinyConfig) GetWithSource(host, key string) (string, string, error) { return c[fmt.Sprintf("%s:%s", host, key)], c["_source"], nil } +func (c tinyConfig) Get(host, key string) (val string, err error) { + val, _, err = c.GetWithSource(host, key) + return +} + func Test_helperRun(t *testing.T) { tests := []struct { name string @@ -74,6 +80,32 @@ func Test_helperRun(t *testing.T) { `), wantStderr: "", }, + { + name: "gist host", + opts: CredentialOptions{ + Operation: "get", + Config: func() (config, error) { + return tinyConfig{ + "_source": "/Users/monalisa/.config/gh/hosts.yml", + "github.com:user": "monalisa", + "github.com:oauth_token": "OTOKEN", + }, nil + }, + }, + input: heredoc.Doc(` + protocol=https + host=gist.github.com + username=monalisa + `), + wantErr: false, + wantStdout: heredoc.Doc(` + protocol=https + host=gist.github.com + username=monalisa + password=OTOKEN + `), + wantStderr: "", + }, { name: "url input", opts: CredentialOptions{ diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index ad6aec33d..51e74861d 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -38,8 +38,6 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm IO: f.IOStreams, Config: f.Config, HttpClient: f.HttpClient, - - MainExecutable: f.Executable, } var tokenStdin bool @@ -70,39 +68,37 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm $ gh auth login --hostname enterprise.internal `), RunE: func(cmd *cobra.Command, args []string) error { - if !opts.IO.CanPrompt() && !(tokenStdin || opts.Web) { - return &cmdutil.FlagError{Err: errors.New("--web or --with-token required when not running interactively")} - } - if tokenStdin && opts.Web { - return &cmdutil.FlagError{Err: errors.New("specify only one of --web or --with-token")} + return cmdutil.FlagErrorf("specify only one of `--web` or `--with-token`") + } + if tokenStdin && len(opts.Scopes) > 0 { + return cmdutil.FlagErrorf("specify only one of `--scopes` or `--with-token`") } if tokenStdin { defer opts.IO.In.Close() token, err := ioutil.ReadAll(opts.IO.In) if err != nil { - return fmt.Errorf("failed to read token from STDIN: %w", err) + return fmt.Errorf("failed to read token from standard input: %w", err) } opts.Token = strings.TrimSpace(string(token)) } - if opts.IO.CanPrompt() && opts.Token == "" && !opts.Web { + if opts.IO.CanPrompt() && opts.Token == "" { opts.Interactive = true } if cmd.Flags().Changed("hostname") { if err := ghinstance.HostnameValidator(opts.Hostname); err != nil { - return &cmdutil.FlagError{Err: fmt.Errorf("error parsing --hostname: %w", err)} + return cmdutil.FlagErrorf("error parsing hostname: %w", err) } } - if !opts.Interactive { - if opts.Hostname == "" { - opts.Hostname = ghinstance.Default() - } + if opts.Hostname == "" && (!opts.Interactive || opts.Web) { + opts.Hostname = ghinstance.Default() } + opts.MainExecutable = f.Executable() if runF != nil { return runF(opts) } @@ -126,15 +122,11 @@ func loginRun(opts *LoginOptions) error { } hostname := opts.Hostname - if hostname == "" { - if opts.Interactive { - var err error - hostname, err = promptForHostname() - if err != nil { - return err - } - } else { - return errors.New("must specify --hostname") + if opts.Interactive && hostname == "" { + var err error + hostname, err = promptForHostname() + if err != nil { + return err } } diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index c3cde03cf..b7c8438cb 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "regexp" + "runtime" "testing" "github.com/MakeNowJust/heredoc" @@ -18,6 +19,21 @@ import ( "github.com/stretchr/testify/assert" ) +func stubHomeDir(t *testing.T, dir string) { + homeEnv := "HOME" + switch runtime.GOOS { + case "windows": + homeEnv = "USERPROFILE" + case "plan9": + homeEnv = "home" + } + oldHomeDir := os.Getenv(homeEnv) + os.Setenv(homeEnv, dir) + t.Cleanup(func() { + os.Setenv(homeEnv, oldHomeDir) + }) +} + func Test_NewCmdLogin(t *testing.T) { tests := []struct { name string @@ -50,13 +66,19 @@ func Test_NewCmdLogin(t *testing.T) { name: "nontty, hostname", stdinTTY: false, cli: "--hostname claire.redfield", - wantsErr: true, + wants: LoginOptions{ + Hostname: "claire.redfield", + Token: "", + }, }, { name: "nontty", stdinTTY: false, cli: "", - wantsErr: true, + wants: LoginOptions{ + Hostname: "github.com", + Token: "", + }, }, { name: "nontty, with-token, hostname", @@ -102,8 +124,9 @@ func Test_NewCmdLogin(t *testing.T) { stdinTTY: true, cli: "--web", wants: LoginOptions{ - Hostname: "github.com", - Web: true, + Hostname: "github.com", + Web: true, + Interactive: true, }, }, { @@ -345,6 +368,8 @@ func Test_loginRun_nontty(t *testing.T) { } func Test_loginRun_Survey(t *testing.T) { + stubHomeDir(t, t.TempDir()) + tests := []struct { name string opts *LoginOptions @@ -370,8 +395,8 @@ func Test_loginRun_Survey(t *testing.T) { // httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) }, askStubs: func(as *prompt.AskStubber) { - as.StubOne(0) // host type github.com - as.StubOne(false) // do not continue + as.StubPrompt("What account do you want to log into?").AnswerWith("GitHub.com") + as.StubPrompt("You're already logged into github.com. Do you want to re-authenticate?").AnswerWith(false) }, wantHosts: "", // nothing should have been written to hosts wantErrOut: nil, @@ -389,10 +414,10 @@ func Test_loginRun_Survey(t *testing.T) { git_protocol: https `), askStubs: func(as *prompt.AskStubber) { - as.StubOne("HTTPS") // git_protocol - as.StubOne(false) // cache credentials - as.StubOne(1) // auth mode: token - as.StubOne("def456") // auth token + as.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("HTTPS") + as.StubPrompt("Authenticate Git with your GitHub credentials?").AnswerWith(false) + as.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token") + as.StubPrompt("Paste your authentication token:").AnswerWith("def456") }, runStubs: func(rs *run.CommandStubber) { rs.Register(`git config credential\.https:/`, 1, "") @@ -418,12 +443,12 @@ func Test_loginRun_Survey(t *testing.T) { Interactive: true, }, askStubs: func(as *prompt.AskStubber) { - as.StubOne(1) // host type enterprise - as.StubOne("brad.vickers") // hostname - as.StubOne("HTTPS") // git_protocol - as.StubOne(false) // cache credentials - as.StubOne(1) // auth mode: token - as.StubOne("def456") // auth token + as.StubPrompt("What account do you want to log into?").AnswerWith("GitHub Enterprise Server") + as.StubPrompt("GHE hostname:").AnswerWith("brad.vickers") + as.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("HTTPS") + as.StubPrompt("Authenticate Git with your GitHub credentials?").AnswerWith(false) + as.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token") + as.StubPrompt("Paste your authentication token:").AnswerWith("def456") }, runStubs: func(rs *run.CommandStubber) { rs.Register(`git config credential\.https:/`, 1, "") @@ -449,11 +474,11 @@ func Test_loginRun_Survey(t *testing.T) { Interactive: true, }, askStubs: func(as *prompt.AskStubber) { - as.StubOne(0) // host type github.com - as.StubOne("HTTPS") // git_protocol - as.StubOne(false) // cache credentials - as.StubOne(1) // auth mode: token - as.StubOne("def456") // auth token + as.StubPrompt("What account do you want to log into?").AnswerWith("GitHub.com") + as.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("HTTPS") + as.StubPrompt("Authenticate Git with your GitHub credentials?").AnswerWith(false) + as.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token") + as.StubPrompt("Paste your authentication token:").AnswerWith("def456") }, runStubs: func(rs *run.CommandStubber) { rs.Register(`git config credential\.https:/`, 1, "") @@ -473,11 +498,11 @@ func Test_loginRun_Survey(t *testing.T) { Interactive: true, }, askStubs: func(as *prompt.AskStubber) { - as.StubOne(0) // host type github.com - as.StubOne("SSH") // git_protocol - as.StubOne(10) // TODO: SSH key selection - as.StubOne(1) // auth mode: token - as.StubOne("def456") // auth token + as.StubPrompt("What account do you want to log into?").AnswerWith("GitHub.com") + as.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("SSH") + as.StubPrompt("Generate a new SSH key to add to your GitHub account?").AnswerWith(false) + as.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token") + as.StubPrompt("Paste your authentication token:").AnswerWith("def456") }, wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://github.com/settings/tokens"), }, @@ -523,8 +548,7 @@ func Test_loginRun_Survey(t *testing.T) { hostsBuf := bytes.Buffer{} defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - as, teardown := prompt.InitAskStubber() - defer teardown() + as := prompt.NewAskStubber(t) if tt.askStubs != nil { tt.askStubs(as) } diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index 670c4cc45..3873da324 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -48,7 +48,7 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co `), RunE: func(cmd *cobra.Command, args []string) error { if opts.Hostname == "" && !opts.IO.CanPrompt() { - return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")} + return cmdutil.FlagErrorf("--hostname required when not running interactively") } if runF != nil { diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index a5d46ef09..4d74caaf5 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -106,8 +106,8 @@ func Test_logoutRun_tty(t *testing.T) { cfgHosts: []string{"cheryl.mason", "github.com"}, wantHosts: "cheryl.mason:\n oauth_token: abc123\n", askStubs: func(as *prompt.AskStubber) { - as.StubOne("github.com") - as.StubOne(true) + as.StubPrompt("What account do you want to log out of?").AnswerWith("github.com") + as.StubPrompt("Are you sure you want to log out of github.com account 'cybilb'?").AnswerWith(true) }, wantErrOut: regexp.MustCompile(`Logged out of github.com account 'cybilb'`), }, @@ -116,7 +116,7 @@ func Test_logoutRun_tty(t *testing.T) { opts: &LogoutOptions{}, cfgHosts: []string{"github.com"}, askStubs: func(as *prompt.AskStubber) { - as.StubOne(true) + as.StubPrompt("Are you sure you want to log out of github.com account 'cybilb'?").AnswerWith(true) }, wantErrOut: regexp.MustCompile(`Logged out of github.com account 'cybilb'`), }, @@ -133,7 +133,7 @@ func Test_logoutRun_tty(t *testing.T) { cfgHosts: []string{"cheryl.mason", "github.com"}, wantHosts: "github.com:\n oauth_token: abc123\n", askStubs: func(as *prompt.AskStubber) { - as.StubOne(true) + as.StubPrompt("Are you sure you want to log out of cheryl.mason account 'cybilb'?").AnswerWith(true) }, wantErrOut: regexp.MustCompile(`Logged out of cheryl.mason account 'cybilb'`), }, @@ -169,8 +169,7 @@ func Test_logoutRun_tty(t *testing.T) { hostsBuf := bytes.Buffer{} defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - as, teardown := prompt.InitAskStubber() - defer teardown() + as := prompt.NewAskStubber(t) if tt.askStubs != nil { tt.askStubs(as) } diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index a756c30b1..4ef58f73c 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -3,6 +3,8 @@ package refresh import ( "errors" "fmt" + "net/http" + "strings" "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" @@ -16,14 +18,15 @@ import ( ) type RefreshOptions struct { - IO *iostreams.IOStreams - Config func() (config.Config, error) + IO *iostreams.IOStreams + Config func() (config.Config, error) + httpClient *http.Client MainExecutable string Hostname string Scopes []string - AuthFlow func(config.Config, *iostreams.IOStreams, string, []string) error + AuthFlow func(config.Config, *iostreams.IOStreams, string, []string, bool) error Interactive bool } @@ -32,11 +35,11 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. opts := &RefreshOptions{ IO: f.IOStreams, Config: f.Config, - AuthFlow: func(cfg config.Config, io *iostreams.IOStreams, hostname string, scopes []string) error { - _, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes) + AuthFlow: func(cfg config.Config, io *iostreams.IOStreams, hostname string, scopes []string, interactive bool) error { + _, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes, interactive) return err }, - MainExecutable: f.Executable, + httpClient: http.DefaultClient, } cmd := &cobra.Command{ @@ -59,9 +62,10 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. opts.Interactive = opts.IO.CanPrompt() if !opts.Interactive && opts.Hostname == "" { - return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")} + return cmdutil.FlagErrorf("--hostname required when not running interactively") } + opts.MainExecutable = f.Executable() if runF != nil { return runF(opts) } @@ -128,9 +132,21 @@ func refreshRun(opts *RefreshOptions) error { } var additionalScopes []string + if oldToken, _ := cfg.Get(hostname, "oauth_token"); oldToken != "" { + if oldScopes, err := shared.GetScopes(opts.httpClient, hostname, oldToken); err == nil { + for _, s := range strings.Split(oldScopes, ",") { + s = strings.TrimSpace(s) + if s != "" { + additionalScopes = append(additionalScopes, s) + } + } + } + } - credentialFlow := &shared.GitCredentialFlow{} - gitProtocol, _ := cfg.Get(hostname, "git_protocol") + credentialFlow := &shared.GitCredentialFlow{ + Executable: opts.MainExecutable, + } + gitProtocol, _ := cfg.GetOrDefault(hostname, "git_protocol") if opts.Interactive && gitProtocol == "https" { if err := credentialFlow.Prompt(hostname); err != nil { return err @@ -138,10 +154,13 @@ func refreshRun(opts *RefreshOptions) error { additionalScopes = append(additionalScopes, credentialFlow.Scopes()...) } - if err := opts.AuthFlow(cfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...)); err != nil { + if err := opts.AuthFlow(cfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...), opts.Interactive); err != nil { return err } + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.ErrOut, "%s Authentication complete.\n", cs.SuccessIcon()) + if credentialFlow.ShouldSetup() { username, _ := cfg.Get(hostname, "user") password, _ := cfg.Get(hostname, "oauth_token") diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index 02941cd9b..1bee8435d 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -2,6 +2,9 @@ package refresh import ( "bytes" + "io/ioutil" + "net/http" + "strings" "testing" "github.com/cli/cli/v2/internal/config" @@ -133,6 +136,7 @@ func Test_refreshRun(t *testing.T) { opts *RefreshOptions askStubs func(*prompt.AskStubber) cfgHosts []string + oldScopes string wantErr string nontty bool wantAuthArgs authArgs @@ -190,7 +194,7 @@ func Test_refreshRun(t *testing.T) { Hostname: "", }, askStubs: func(as *prompt.AskStubber) { - as.StubOne("github.com") + as.StubPrompt("What account do you want to refresh auth for?").AnswerWith("github.com") }, wantAuthArgs: authArgs{ hostname: "github.com", @@ -210,11 +214,25 @@ func Test_refreshRun(t *testing.T) { scopes: []string{"repo:invite", "public_key:read"}, }, }, + { + name: "scopes provided", + cfgHosts: []string{ + "github.com", + }, + oldScopes: "delete_repo, codespace", + opts: &RefreshOptions{ + Scopes: []string{"repo:invite", "public_key:read"}, + }, + wantAuthArgs: authArgs{ + hostname: "github.com", + scopes: []string{"repo:invite", "public_key:read", "delete_repo", "codespace"}, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { aa := authArgs{} - tt.opts.AuthFlow = func(_ config.Config, _ *iostreams.IOStreams, hostname string, scopes []string) error { + tt.opts.AuthFlow = func(_ config.Config, _ *iostreams.IOStreams, hostname string, scopes []string, interactive bool) error { aa.hostname = hostname aa.scopes = scopes return nil @@ -233,17 +251,32 @@ func Test_refreshRun(t *testing.T) { for _, hostname := range tt.cfgHosts { _ = cfg.Set(hostname, "oauth_token", "abc123") } - reg := &httpmock.Registry{} - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"cybilb"}}}`)) + + httpReg := &httpmock.Registry{} + httpReg.Register( + httpmock.REST("GET", ""), + func(req *http.Request) (*http.Response, error) { + statusCode := 200 + if req.Header.Get("Authorization") != "token abc123" { + statusCode = 400 + } + return &http.Response{ + Request: req, + StatusCode: statusCode, + Body: ioutil.NopCloser(strings.NewReader(``)), + Header: http.Header{ + "X-Oauth-Scopes": {tt.oldScopes}, + }, + }, nil + }, + ) + tt.opts.httpClient = &http.Client{Transport: httpReg} mainBuf := bytes.Buffer{} hostsBuf := bytes.Buffer{} defer config.StubWriteConfig(&mainBuf, &hostsBuf)() - as, teardown := prompt.InitAskStubber() - defer teardown() + as := prompt.NewAskStubber(t) if tt.askStubs != nil { tt.askStubs(as) } @@ -257,8 +290,8 @@ func Test_refreshRun(t *testing.T) { assert.NoError(t, err) } - assert.Equal(t, aa.hostname, tt.wantAuthArgs.hostname) - assert.Equal(t, aa.scopes, tt.wantAuthArgs.scopes) + assert.Equal(t, tt.wantAuthArgs.hostname, aa.hostname) + assert.Equal(t, tt.wantAuthArgs.scopes, aa.scopes) }) } } diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go new file mode 100644 index 000000000..5295ff424 --- /dev/null +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -0,0 +1,100 @@ +package setupgit + +import ( + "fmt" + "strings" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/cmd/auth/shared" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type gitConfigurator interface { + Setup(hostname, username, authToken string) error +} + +type SetupGitOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + Hostname string + gitConfigure gitConfigurator +} + +func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobra.Command { + opts := &SetupGitOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Short: "Configure git to use GitHub CLI as a credential helper", + Use: "setup-git", + RunE: func(cmd *cobra.Command, args []string) error { + opts.gitConfigure = &shared.GitCredentialFlow{ + Executable: f.Executable(), + } + + if runF != nil { + return runF(opts) + } + return setupGitRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname to configure git for") + + return cmd +} + +func setupGitRun(opts *SetupGitOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + hostnames, err := cfg.Hosts() + if err != nil { + return err + } + + stderr := opts.IO.ErrOut + cs := opts.IO.ColorScheme() + + if len(hostnames) == 0 { + fmt.Fprintf( + stderr, + "You are not logged into any GitHub hosts. Run %s to authenticate.\n", + cs.Bold("gh auth login"), + ) + + return cmdutil.SilentError + } + + hostnamesToSetup := hostnames + + if opts.Hostname != "" { + if !has(opts.Hostname, hostnames) { + return fmt.Errorf("You are not logged into the GitHub host %q\n", opts.Hostname) + } + hostnamesToSetup = []string{opts.Hostname} + } + + for _, hostname := range hostnamesToSetup { + if err := opts.gitConfigure.Setup(hostname, "", ""); err != nil { + return fmt.Errorf("failed to set up git credential helper: %w", err) + } + } + + return nil +} + +func has(needle string, haystack []string) bool { + for _, s := range haystack { + if strings.EqualFold(s, needle) { + return true + } + } + return false +} diff --git a/pkg/cmd/auth/setupgit/setupgit_test.go b/pkg/cmd/auth/setupgit/setupgit_test.go new file mode 100644 index 000000000..52bc3a5b0 --- /dev/null +++ b/pkg/cmd/auth/setupgit/setupgit_test.go @@ -0,0 +1,122 @@ +package setupgit + +import ( + "fmt" + "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockGitConfigurer struct { + setupErr error +} + +func (gf *mockGitConfigurer) Setup(hostname, username, authToken string) error { + return gf.setupErr +} + +func Test_setupGitRun(t *testing.T) { + tests := []struct { + name string + opts *SetupGitOptions + expectedErr string + expectedErrOut string + }{ + { + name: "opts.Config returns an error", + opts: &SetupGitOptions{ + Config: func() (config.Config, error) { + return nil, fmt.Errorf("oops") + }, + }, + expectedErr: "oops", + }, + { + name: "no authenticated hostnames", + opts: &SetupGitOptions{}, + expectedErr: "SilentError", + expectedErrOut: "You are not logged into any GitHub hosts. Run gh auth login to authenticate.\n", + }, + { + name: "not authenticated with the hostname given as flag", + opts: &SetupGitOptions{ + Hostname: "foo", + Config: func() (config.Config, error) { + cfg := config.NewBlankConfig() + require.NoError(t, cfg.Set("bar", "", "")) + return cfg, nil + }, + }, + expectedErr: "You are not logged into the GitHub host \"foo\"\n", + expectedErrOut: "", + }, + { + name: "error setting up git for hostname", + opts: &SetupGitOptions{ + gitConfigure: &mockGitConfigurer{ + setupErr: fmt.Errorf("broken"), + }, + Config: func() (config.Config, error) { + cfg := config.NewBlankConfig() + require.NoError(t, cfg.Set("bar", "", "")) + return cfg, nil + }, + }, + expectedErr: "failed to set up git credential helper: broken", + expectedErrOut: "", + }, + { + name: "no hostname option given. Setup git for each hostname in config", + opts: &SetupGitOptions{ + gitConfigure: &mockGitConfigurer{}, + Config: func() (config.Config, error) { + cfg := config.NewBlankConfig() + require.NoError(t, cfg.Set("bar", "", "")) + return cfg, nil + }, + }, + }, + { + name: "setup git for the hostname given via options", + opts: &SetupGitOptions{ + Hostname: "yes", + gitConfigure: &mockGitConfigurer{}, + Config: func() (config.Config, error) { + cfg := config.NewBlankConfig() + require.NoError(t, cfg.Set("bar", "", "")) + require.NoError(t, cfg.Set("yes", "", "")) + return cfg, nil + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.opts.Config == nil { + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + } + + io, _, _, stderr := iostreams.Test() + + io.SetStdinTTY(true) + io.SetStderrTTY(true) + io.SetStdoutTTY(true) + tt.opts.IO = io + + err := setupGitRun(tt.opts) + if tt.expectedErr != "" { + assert.EqualError(t, err, tt.expectedErr) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.expectedErrOut, stderr.String()) + }) + } +} diff --git a/pkg/cmd/auth/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go index 57a9b8429..fb8ba31c2 100644 --- a/pkg/cmd/auth/shared/git_credential.go +++ b/pkg/cmd/auth/shared/git_credential.go @@ -10,6 +10,7 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/git" + "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/prompt" "github.com/google/shlex" @@ -62,25 +63,46 @@ func (flow *GitCredentialFlow) Setup(hostname, username, authToken string) error func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password string) error { if flow.helper == "" { - // first use a blank value to indicate to git we want to sever the chain of credential helpers - preConfigureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "") - if err != nil { - return err - } - if err = run.PrepareCmd(preConfigureCmd).Run(); err != nil { - return err + credHelperKeys := []string{ + gitCredentialHelperKey(hostname), } - // use GitHub CLI as a credential helper (for this host only) - configureCmd, err := git.GitCommand( - "config", "--global", "--add", - gitCredentialHelperKey(hostname), - fmt.Sprintf("!%s auth git-credential", shellQuote(flow.Executable)), - ) - if err != nil { - return err + gistHost := strings.TrimSuffix(ghinstance.GistHost(hostname), "/") + if strings.HasPrefix(gistHost, "gist.") { + credHelperKeys = append(credHelperKeys, gitCredentialHelperKey(gistHost)) } - return run.PrepareCmd(configureCmd).Run() + + var configErr error + + for _, credHelperKey := range credHelperKeys { + if configErr != nil { + break + } + // first use a blank value to indicate to git we want to sever the chain of credential helpers + preConfigureCmd, err := git.GitCommand("config", "--global", "--replace-all", credHelperKey, "") + if err != nil { + configErr = err + break + } + if err = run.PrepareCmd(preConfigureCmd).Run(); err != nil { + configErr = err + break + } + + // second configure the actual helper for this host + configureCmd, err := git.GitCommand( + "config", "--global", "--add", + credHelperKey, + fmt.Sprintf("!%s auth git-credential", shellQuote(flow.Executable)), + ) + if err != nil { + configErr = err + } else { + configErr = run.PrepareCmd(configureCmd).Run() + } + } + + return configErr } // clear previous cached credentials @@ -120,7 +142,8 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s } func gitCredentialHelperKey(hostname string) string { - return fmt.Sprintf("credential.https://%s.helper", hostname) + host := strings.TrimSuffix(ghinstance.HostPrefix(hostname), "/") + return fmt.Sprintf("credential.%s.helper", host) } func gitCredentialHelper(hostname string) (helper string, err error) { diff --git a/pkg/cmd/auth/shared/git_credential_test.go b/pkg/cmd/auth/shared/git_credential_test.go index 58fe3988a..fe674e1d7 100644 --- a/pkg/cmd/auth/shared/git_credential_test.go +++ b/pkg/cmd/auth/shared/git_credential_test.go @@ -22,10 +22,57 @@ func TestGitCredentialSetup_configureExisting(t *testing.T) { } } -func TestGitCredentialSetup_setOurs(t *testing.T) { +func TestGitCredentialsSetup_setOurs_GH(t *testing.T) { cs, restoreRun := run.Stub() defer restoreRun(t) - cs.Register(`git config --global credential\.`, 0, "", func(args []string) { + cs.Register(`git config --global --replace-all credential\.`, 0, "", func(args []string) { + if key := args[len(args)-2]; key != "credential.https://github.com.helper" { + t.Errorf("git config key was %q", key) + } + if val := args[len(args)-1]; val != "" { + t.Errorf("global credential helper configured to %q", val) + } + }) + cs.Register(`git config --global --add credential\.`, 0, "", func(args []string) { + if key := args[len(args)-2]; key != "credential.https://github.com.helper" { + t.Errorf("git config key was %q", key) + } + if val := args[len(args)-1]; val != "!/path/to/gh auth git-credential" { + t.Errorf("global credential helper configured to %q", val) + } + }) + cs.Register(`git config --global --replace-all credential\.`, 0, "", func(args []string) { + if key := args[len(args)-2]; key != "credential.https://gist.github.com.helper" { + t.Errorf("git config key was %q", key) + } + if val := args[len(args)-1]; val != "" { + t.Errorf("global credential helper configured to %q", val) + } + }) + cs.Register(`git config --global --add credential\.`, 0, "", func(args []string) { + if key := args[len(args)-2]; key != "credential.https://gist.github.com.helper" { + t.Errorf("git config key was %q", key) + } + if val := args[len(args)-1]; val != "!/path/to/gh auth git-credential" { + t.Errorf("global credential helper configured to %q", val) + } + }) + + f := GitCredentialFlow{ + Executable: "/path/to/gh", + helper: "", + } + + if err := f.gitCredentialSetup("github.com", "monalisa", "PASSWD"); err != nil { + t.Errorf("GitCredentialSetup() error = %v", err) + } + +} + +func TestGitCredentialSetup_setOurs_nonGH(t *testing.T) { + cs, restoreRun := run.Stub() + defer restoreRun(t) + cs.Register(`git config --global --replace-all credential\.`, 0, "", func(args []string) { if key := args[len(args)-2]; key != "credential.https://example.com.helper" { t.Errorf("git config key was %q", key) } diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 0bac49b35..3570106da 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -99,7 +99,7 @@ func Login(opts *LoginOptions) error { var authMode int if opts.Web { authMode = 0 - } else { + } else if opts.Interactive { err := prompt.SurveyAskOne(&survey.Select{ Message: "How would you like to authenticate GitHub CLI?", Options: []string{ @@ -117,10 +117,11 @@ func Login(opts *LoginOptions) error { if authMode == 0 { var err error - authToken, err = authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", append(opts.Scopes, additionalScopes...)) + authToken, err = authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", append(opts.Scopes, additionalScopes...), opts.Interactive) if err != nil { return fmt.Errorf("failed to authenticate via web browser: %w", err) } + fmt.Fprintf(opts.IO.ErrOut, "%s Authentication complete.\n", cs.SuccessIcon()) userValidated = true } else { minimumScopes := append([]string{"repo", "read:org"}, additionalScopes...) diff --git a/pkg/cmd/auth/shared/login_flow_test.go b/pkg/cmd/auth/shared/login_flow_test.go index 530e34045..6f1b35ebe 100644 --- a/pkg/cmd/auth/shared/login_flow_test.go +++ b/pkg/cmd/auth/shared/login_flow_test.go @@ -47,14 +47,13 @@ func TestLogin_ssh(t *testing.T) { httpmock.REST("POST", "api/v3/user/keys"), httpmock.StringResponse(`{}`)) - ask, askRestore := prompt.InitAskStubber() - defer askRestore() + ask := prompt.NewAskStubber(t) - ask.StubOne("SSH") // preferred protocol - ask.StubOne(true) // generate a new key - ask.StubOne("monkey") // enter a passphrase - ask.StubOne(1) // paste a token - ask.StubOne("ATOKEN") // token + ask.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("SSH") + ask.StubPrompt("Generate a new SSH key to add to your GitHub account?").AnswerWith(true) + ask.StubPrompt("Enter a passphrase for your new SSH key (Optional)").AnswerWith("monkey") + ask.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token") + ask.StubPrompt("Paste your authentication token:").AnswerWith("ATOKEN") rs, runRestore := run.Stub() defer runRestore(t) diff --git a/pkg/cmd/auth/shared/oauth_scopes.go b/pkg/cmd/auth/shared/oauth_scopes.go index 35619ea7b..c076722b2 100644 --- a/pkg/cmd/auth/shared/oauth_scopes.go +++ b/pkg/cmd/auth/shared/oauth_scopes.go @@ -32,19 +32,19 @@ type httpClient interface { Do(*http.Request) (*http.Response, error) } -func HasMinimumScopes(httpClient httpClient, hostname, authToken string) error { +func GetScopes(httpClient httpClient, hostname, authToken string) (string, error) { apiEndpoint := ghinstance.RESTPrefix(hostname) req, err := http.NewRequest("GET", apiEndpoint, nil) if err != nil { - return err + return "", err } req.Header.Set("Authorization", "token "+authToken) res, err := httpClient.Do(req) if err != nil { - return err + return "", err } defer func() { @@ -55,10 +55,18 @@ func HasMinimumScopes(httpClient httpClient, hostname, authToken string) error { }() if res.StatusCode != 200 { - return api.HandleHTTPError(res) + return "", api.HandleHTTPError(res) + } + + return res.Header.Get("X-Oauth-Scopes"), nil +} + +func HasMinimumScopes(httpClient httpClient, hostname, authToken string) error { + scopesHeader, err := GetScopes(httpClient, hostname, authToken) + if err != nil { + return err } - scopesHeader := res.Header.Get("X-Oauth-Scopes") if scopesHeader == "" { // if the token reports no scopes, assume that it's an integration token and give up on // detecting its capabilities diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index f84004c87..e09273e99 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -35,7 +35,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co Args: cobra.ExactArgs(0), Short: "View authentication status", Long: heredoc.Doc(`Verifies and displays information about your authentication state. - + This command will test your authentication state for each GitHub host that gh knows about and report on any issues. `), @@ -127,7 +127,7 @@ func statusRun(opts *StatusOptions) error { addMsg("%s %s: api call failed: %s", cs.Red("X"), hostname, err) } addMsg("%s Logged in to %s as %s (%s)", cs.SuccessIcon(), hostname, cs.Bold(username), tokenSource) - proto, _ := cfg.Get(hostname, "git_protocol") + proto, _ := cfg.GetOrDefault(hostname, "git_protocol") if proto != "" { addMsg("%s Git operations for %s configured to use %s protocol.", cs.SuccessIcon(), hostname, cs.Bold(proto)) diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go index fdb500cbf..7d29f9723 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -3,14 +3,19 @@ package browse import ( "fmt" "net/http" + "net/url" + "path" + "path/filepath" "strconv" "strings" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/utils" "github.com/spf13/cobra" ) @@ -19,14 +24,17 @@ type browser interface { } type BrowseOptions struct { - BaseRepo func() (ghrepo.Interface, error) - Browser browser - HttpClient func() (*http.Client, error) - IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Browser browser + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + PathFromRepoRoot func() string + GitClient gitClient SelectorArg string Branch string + CommitFlag bool ProjectsFlag bool SettingsFlag bool WikiFlag bool @@ -35,9 +43,11 @@ type BrowseOptions struct { func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Command { opts := &BrowseOptions{ - Browser: f.Browser, - HttpClient: f.HttpClient, - IO: f.IOStreams, + Browser: f.Browser, + HttpClient: f.HttpClient, + IO: f.IOStreams, + PathFromRepoRoot: git.PathFromRepoRoot, + GitClient: &localGitClient{}, } cmd := &cobra.Command{ @@ -80,14 +90,18 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co } if err := cmdutil.MutuallyExclusive( - "specify only one of `--branch`, `--projects`, `--wiki`, or `--settings`", + "specify only one of `--branch`, `--commit`, `--projects`, `--wiki`, or `--settings`", opts.Branch != "", + opts.CommitFlag, opts.WikiFlag, opts.SettingsFlag, opts.ProjectsFlag, ); err != nil { return err } + if cmd.Flags().Changed("repo") { + opts.GitClient = &remoteGitClient{opts.BaseRepo, opts.HttpClient} + } if runF != nil { return runF(opts) @@ -101,6 +115,7 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Open repository wiki") cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Open repository settings") cmd.Flags().BoolVarP(&opts.NoBrowserFlag, "no-browser", "n", false, "Print destination URL instead of opening the browser") + cmd.Flags().BoolVarP(&opts.CommitFlag, "commit", "c", false, "Open the last commit") cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Select another branch by passing in the branch name") return cmd @@ -112,89 +127,157 @@ func runBrowse(opts *BrowseOptions) error { return fmt.Errorf("unable to determine base repository: %w", err) } - httpClient, err := opts.HttpClient() - if err != nil { - return fmt.Errorf("unable to create an http client: %w", err) + if opts.CommitFlag { + commit, err := opts.GitClient.LastCommit() + if err != nil { + return err + } + opts.Branch = commit.Sha } - url := ghrepo.GenerateRepoURL(baseRepo, "") - if opts.SelectorArg == "" { - if opts.ProjectsFlag { - url += "/projects" - } - if opts.SettingsFlag { - url += "/settings" - } - if opts.WikiFlag { - url += "/wiki" - } - if opts.Branch != "" { - url += "/tree/" + opts.Branch + "/" - } - } else { - if isNumber(opts.SelectorArg) { - url += "/issues/" + opts.SelectorArg - } else { - fileArg, err := parseFileArg(opts.SelectorArg) - if err != nil { - return err - } - if opts.Branch != "" { - url += "/tree/" + opts.Branch + "/" - } else { - apiClient := api.NewClientFromHTTP(httpClient) - branchName, err := api.RepoDefaultBranch(apiClient, baseRepo) - if err != nil { - return err - } - url += "/tree/" + branchName + "/" - } - url += fileArg - } + section, err := parseSection(baseRepo, opts) + if err != nil { + return err } + url := ghrepo.GenerateRepoURL(baseRepo, "%s", section) if opts.NoBrowserFlag { - fmt.Fprintf(opts.IO.Out, "%s\n", url) - return nil - } else { - if opts.IO.IsStdoutTTY() { - fmt.Fprintf(opts.IO.Out, "now opening %s in browser\n", url) - } - return opts.Browser.Browse(url) + _, err := fmt.Fprintln(opts.IO.Out, url) + return err } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(url)) + } + return opts.Browser.Browse(url) } -func parseFileArg(fileArg string) (string, error) { - arr := strings.Split(fileArg, ":") - if len(arr) > 2 { - return "", fmt.Errorf("invalid use of colon\nUse 'gh browse --help' for more information about browse\n") +func parseSection(baseRepo ghrepo.Interface, opts *BrowseOptions) (string, error) { + if opts.SelectorArg == "" { + if opts.ProjectsFlag { + return "projects", nil + } else if opts.SettingsFlag { + return "settings", nil + } else if opts.WikiFlag { + return "wiki", nil + } else if opts.Branch == "" { + return "", nil + } } - if len(arr) > 1 { - out := arr[0] + "#L" - lineRange := strings.Split(arr[1], "-") - - if len(lineRange) > 0 { - if !isNumber(lineRange[0]) { - return "", fmt.Errorf("invalid line number after colon\nUse 'gh browse --help' for more information about browse\n") - } - out += lineRange[0] - } - - if len(lineRange) > 1 { - if !isNumber(lineRange[1]) { - return "", fmt.Errorf("invalid line range after colon\nUse 'gh browse --help' for more information about browse\n") - } - out += "-L" + lineRange[1] - } - - return out, nil + if isNumber(opts.SelectorArg) { + return fmt.Sprintf("issues/%s", opts.SelectorArg), nil } - return arr[0], nil + filePath, rangeStart, rangeEnd, err := parseFile(*opts, opts.SelectorArg) + if err != nil { + return "", err + } + + branchName := opts.Branch + if branchName == "" { + httpClient, err := opts.HttpClient() + if err != nil { + return "", err + } + apiClient := api.NewClientFromHTTP(httpClient) + branchName, err = api.RepoDefaultBranch(apiClient, baseRepo) + if err != nil { + return "", fmt.Errorf("error determining the default branch: %w", err) + } + } + + if rangeStart > 0 { + var rangeFragment string + if rangeEnd > 0 && rangeStart != rangeEnd { + rangeFragment = fmt.Sprintf("L%d-L%d", rangeStart, rangeEnd) + } else { + rangeFragment = fmt.Sprintf("L%d", rangeStart) + } + return fmt.Sprintf("blob/%s/%s?plain=1#%s", escapePath(branchName), escapePath(filePath), rangeFragment), nil + } + return strings.TrimSuffix(fmt.Sprintf("tree/%s/%s", escapePath(branchName), escapePath(filePath)), "/"), nil +} + +// escapePath URL-encodes special characters but leaves slashes unchanged +func escapePath(p string) string { + return strings.ReplaceAll(url.PathEscape(p), "%2F", "/") +} + +func parseFile(opts BrowseOptions, f string) (p string, start int, end int, err error) { + if f == "" { + return + } + + parts := strings.SplitN(f, ":", 3) + if len(parts) > 2 { + err = fmt.Errorf("invalid file argument: %q", f) + return + } + + p = filepath.ToSlash(parts[0]) + if !path.IsAbs(p) { + p = path.Join(opts.PathFromRepoRoot(), p) + if p == "." || strings.HasPrefix(p, "..") { + p = "" + } + } + if len(parts) < 2 { + return + } + + if idx := strings.IndexRune(parts[1], '-'); idx >= 0 { + start, err = strconv.Atoi(parts[1][:idx]) + if err != nil { + err = fmt.Errorf("invalid file argument: %q", f) + return + } + end, err = strconv.Atoi(parts[1][idx+1:]) + if err != nil { + err = fmt.Errorf("invalid file argument: %q", f) + } + return + } + + start, err = strconv.Atoi(parts[1]) + if err != nil { + err = fmt.Errorf("invalid file argument: %q", f) + } + end = start + return } func isNumber(arg string) bool { _, err := strconv.Atoi(arg) return err == nil } + +// gitClient is used to implement functions that can be performed on both local and remote git repositories +type gitClient interface { + LastCommit() (*git.Commit, error) +} + +type localGitClient struct{} + +type remoteGitClient struct { + repo func() (ghrepo.Interface, error) + httpClient func() (*http.Client, error) +} + +func (gc *localGitClient) LastCommit() (*git.Commit, error) { return git.LastCommit() } + +func (gc *remoteGitClient) LastCommit() (*git.Commit, error) { + httpClient, err := gc.httpClient() + if err != nil { + return nil, err + } + repo, err := gc.repo() + if err != nil { + return nil, err + } + commit, err := api.LastCommit(api.NewClientFromHTTP(httpClient), repo) + if err != nil { + return nil, err + } + return &git.Commit{Sha: commit.OID}, nil +} diff --git a/pkg/cmd/browse/browse_test.go b/pkg/cmd/browse/browse_test.go index bf3349c3c..36e0ed778 100644 --- a/pkg/cmd/browse/browse_test.go +++ b/pkg/cmd/browse/browse_test.go @@ -2,9 +2,13 @@ package browse import ( "fmt" + "io/ioutil" "net/http" + "os" + "path/filepath" "testing" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -102,6 +106,14 @@ func TestNewCmdBrowse(t *testing.T) { cli: "main.go main.go", wantsErr: true, }, + { + name: "last commit flag", + cli: "-c", + wants: BrowseOptions{ + CommitFlag: true, + }, + wantsErr: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -114,6 +126,8 @@ func TestNewCmdBrowse(t *testing.T) { argv, err := shlex.Split(tt.cli) assert.NoError(t, err) cmd.SetArgs(argv) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) _, err = cmd.ExecuteC() if tt.wantsErr { @@ -129,11 +143,29 @@ func TestNewCmdBrowse(t *testing.T) { assert.Equal(t, tt.wants.WikiFlag, opts.WikiFlag) assert.Equal(t, tt.wants.NoBrowserFlag, opts.NoBrowserFlag) assert.Equal(t, tt.wants.SettingsFlag, opts.SettingsFlag) + assert.Equal(t, tt.wants.CommitFlag, opts.CommitFlag) }) } } +func setGitDir(t *testing.T, dir string) { + // taken from git_test.go + old_GIT_DIR := os.Getenv("GIT_DIR") + os.Setenv("GIT_DIR", dir) + t.Cleanup(func() { + os.Setenv("GIT_DIR", old_GIT_DIR) + }) +} + +type testGitClient struct{} + +func (gc *testGitClient) LastCommit() (*git.Commit, error) { + return &git.Commit{Sha: "6f1a2405cace1633d89a79c74c65f22fe78f9659"}, nil +} + func Test_runBrowse(t *testing.T) { + s := string(os.PathSeparator) + setGitDir(t, "../../../git/fixtures/simple.git") tests := []struct { name string opts BrowseOptions @@ -195,7 +227,7 @@ func Test_runBrowse(t *testing.T) { Branch: "trunk", }, baseRepo: ghrepo.New("jlsestak", "CouldNotThinkOfARepoName"), - expectedURL: "https://github.com/jlsestak/CouldNotThinkOfARepoName/tree/trunk/", + expectedURL: "https://github.com/jlsestak/CouldNotThinkOfARepoName/tree/trunk", }, { name: "branch flag with file", @@ -206,6 +238,35 @@ func Test_runBrowse(t *testing.T) { baseRepo: ghrepo.New("bchadwic", "LedZeppelinIV"), expectedURL: "https://github.com/bchadwic/LedZeppelinIV/tree/trunk/main.go", }, + { + name: "branch flag within dir", + opts: BrowseOptions{ + Branch: "feature-123", + PathFromRepoRoot: func() string { return "pkg/dir" }, + }, + baseRepo: ghrepo.New("bstnc", "yeepers"), + expectedURL: "https://github.com/bstnc/yeepers/tree/feature-123", + }, + { + name: "branch flag within dir with .", + opts: BrowseOptions{ + Branch: "feature-123", + SelectorArg: ".", + PathFromRepoRoot: func() string { return "pkg/dir" }, + }, + baseRepo: ghrepo.New("bstnc", "yeepers"), + expectedURL: "https://github.com/bstnc/yeepers/tree/feature-123/pkg/dir", + }, + { + name: "branch flag within dir with dir", + opts: BrowseOptions{ + Branch: "feature-123", + SelectorArg: "inner/more", + PathFromRepoRoot: func() string { return "pkg/dir" }, + }, + baseRepo: ghrepo.New("bstnc", "yeepers"), + expectedURL: "https://github.com/bstnc/yeepers/tree/feature-123/pkg/dir/inner/more", + }, { name: "file with line number", opts: BrowseOptions{ @@ -213,7 +274,7 @@ func Test_runBrowse(t *testing.T) { }, baseRepo: ghrepo.New("ravocean", "angur"), defaultBranch: "trunk", - expectedURL: "https://github.com/ravocean/angur/tree/trunk/path/to/file.txt#L32", + expectedURL: "https://github.com/ravocean/angur/blob/trunk/path/to/file.txt?plain=1#L32", }, { name: "file with line range", @@ -222,12 +283,37 @@ func Test_runBrowse(t *testing.T) { }, baseRepo: ghrepo.New("ravocean", "angur"), defaultBranch: "trunk", - expectedURL: "https://github.com/ravocean/angur/tree/trunk/path/to/file.txt#L32-L40", + expectedURL: "https://github.com/ravocean/angur/blob/trunk/path/to/file.txt?plain=1#L32-L40", + }, + { + name: "invalid default branch", + opts: BrowseOptions{ + SelectorArg: "chocolate-pecan-pie.txt", + }, + baseRepo: ghrepo.New("andrewhsu", "recipies"), + defaultBranch: "", + wantsErr: true, + }, + { + name: "file with invalid line number after colon", + opts: BrowseOptions{ + SelectorArg: "laptime-notes.txt:w-9", + }, + baseRepo: ghrepo.New("andrewhsu", "sonoma-raceway"), + wantsErr: true, + }, + { + name: "file with invalid file format", + opts: BrowseOptions{ + SelectorArg: "path/to/file.txt:32:32", + }, + baseRepo: ghrepo.New("ttran112", "ttrain211"), + wantsErr: true, }, { name: "file with invalid line number", opts: BrowseOptions{ - SelectorArg: "path/to/file.txt:32:32", + SelectorArg: "path/to/file.txt:32a", }, baseRepo: ghrepo.New("ttran112", "ttrain211"), wantsErr: true, @@ -258,7 +344,7 @@ func Test_runBrowse(t *testing.T) { }, baseRepo: ghrepo.New("github", "ThankYouGitHub"), wantsErr: false, - expectedURL: "https://github.com/github/ThankYouGitHub/tree/first-browse-pull/browse.go#L32", + expectedURL: "https://github.com/github/ThankYouGitHub/blob/first-browse-pull/browse.go?plain=1#L32", }, { name: "no browser with branch file and line number", @@ -269,7 +355,64 @@ func Test_runBrowse(t *testing.T) { }, baseRepo: ghrepo.New("mislav", "will_paginate"), wantsErr: false, - expectedURL: "https://github.com/mislav/will_paginate/tree/3-0-stable/init.rb#L6", + expectedURL: "https://github.com/mislav/will_paginate/blob/3-0-stable/init.rb?plain=1#L6", + }, + { + name: "open last commit", + opts: BrowseOptions{ + CommitFlag: true, + GitClient: &testGitClient{}, + }, + baseRepo: ghrepo.New("vilmibm", "gh-user-status"), + wantsErr: false, + expectedURL: "https://github.com/vilmibm/gh-user-status/tree/6f1a2405cace1633d89a79c74c65f22fe78f9659", + }, + { + name: "open last commit with a file", + opts: BrowseOptions{ + CommitFlag: true, + SelectorArg: "main.go", + GitClient: &testGitClient{}, + }, + baseRepo: ghrepo.New("vilmibm", "gh-user-status"), + wantsErr: false, + expectedURL: "https://github.com/vilmibm/gh-user-status/tree/6f1a2405cace1633d89a79c74c65f22fe78f9659/main.go", + }, + { + name: "relative path from browse_test.go", + opts: BrowseOptions{ + SelectorArg: filepath.Join(".", "browse_test.go"), + PathFromRepoRoot: func() string { + return "pkg/cmd/browse/" + }, + }, + baseRepo: ghrepo.New("bchadwic", "gh-graph"), + defaultBranch: "trunk", + expectedURL: "https://github.com/bchadwic/gh-graph/tree/trunk/pkg/cmd/browse/browse_test.go", + wantsErr: false, + }, + { + name: "relative path to file in parent folder from browse_test.go", + opts: BrowseOptions{ + SelectorArg: ".." + s + "pr", + PathFromRepoRoot: func() string { + return "pkg/cmd/browse/" + }, + }, + baseRepo: ghrepo.New("bchadwic", "gh-graph"), + defaultBranch: "trunk", + expectedURL: "https://github.com/bchadwic/gh-graph/tree/trunk/pkg/cmd/pr", + wantsErr: false, + }, + { + name: "use special characters in selector arg", + opts: BrowseOptions{ + SelectorArg: "?=hello world/ *:23-44", + Branch: "branch/with spaces?", + }, + baseRepo: ghrepo.New("bchadwic", "test"), + expectedURL: "https://github.com/bchadwic/test/blob/branch/with%20spaces%3F/%3F=hello%20world/%20%2A?plain=1#L23-L44", + wantsErr: false, }, } @@ -293,6 +436,9 @@ func Test_runBrowse(t *testing.T) { return &http.Client{Transport: ®}, nil } opts.Browser = &browser + if opts.PathFromRepoRoot == nil { + opts.PathFromRepoRoot = git.PathFromRepoRoot + } err := runBrowse(&opts) if tt.wantsErr { @@ -314,40 +460,102 @@ func Test_runBrowse(t *testing.T) { } } -func Test_parseFileArg(t *testing.T) { +func Test_parsePathFromFileArg(t *testing.T) { tests := []struct { - name string - arg string - errorExpected bool - expectedFileArg string - stderrExpected string + name string + currentDir string + fileArg string + expectedPath string }{ { - name: "non line number", - arg: "main.go", - errorExpected: false, - expectedFileArg: "main.go", + name: "empty paths", + currentDir: "", + fileArg: "", + expectedPath: "", }, { - name: "line number", - arg: "main.go:32", - errorExpected: false, - expectedFileArg: "main.go#L32", + name: "root directory", + currentDir: "", + fileArg: ".", + expectedPath: "", }, { - name: "non line number error", - arg: "ma:in.go", - errorExpected: true, - stderrExpected: "invalid line number after colon\nUse 'gh browse --help' for more information about browse\n", + name: "relative path", + currentDir: "", + fileArg: filepath.FromSlash("foo/bar.py"), + expectedPath: "foo/bar.py", + }, + { + name: "go to parent folder", + currentDir: "pkg/cmd/browse/", + fileArg: filepath.FromSlash("../"), + expectedPath: "pkg/cmd", + }, + { + name: "current folder", + currentDir: "pkg/cmd/browse/", + fileArg: ".", + expectedPath: "pkg/cmd/browse", + }, + { + name: "current folder (alternative)", + currentDir: "pkg/cmd/browse/", + fileArg: filepath.FromSlash("./"), + expectedPath: "pkg/cmd/browse", + }, + { + name: "file that starts with '.'", + currentDir: "pkg/cmd/browse/", + fileArg: ".gitignore", + expectedPath: "pkg/cmd/browse/.gitignore", + }, + { + name: "file in current folder", + currentDir: "pkg/cmd/browse/", + fileArg: filepath.Join(".", "browse.go"), + expectedPath: "pkg/cmd/browse/browse.go", + }, + { + name: "file within parent folder", + currentDir: "pkg/cmd/browse/", + fileArg: filepath.Join("..", "browse.go"), + expectedPath: "pkg/cmd/browse.go", + }, + { + name: "file within parent folder uncleaned", + currentDir: "pkg/cmd/browse/", + fileArg: filepath.FromSlash(".././//browse.go"), + expectedPath: "pkg/cmd/browse.go", + }, + { + name: "different path from root directory", + currentDir: "pkg/cmd/browse/", + fileArg: filepath.Join("..", "..", "..", "internal/build/build.go"), + expectedPath: "internal/build/build.go", + }, + { + name: "go out of repository", + currentDir: "pkg/cmd/browse/", + fileArg: filepath.FromSlash("../../../../../../"), + expectedPath: "", + }, + { + name: "go to root of repository", + currentDir: "pkg/cmd/browse/", + fileArg: filepath.Join("../../../"), + expectedPath: "", + }, + { + name: "empty fileArg", + fileArg: "", + expectedPath: "", }, } for _, tt := range tests { - fileArg, err := parseFileArg(tt.arg) - if tt.errorExpected { - assert.Equal(t, err.Error(), tt.stderrExpected) - } else { - assert.Equal(t, err, nil) - assert.Equal(t, tt.expectedFileArg, fileArg) - } + path, _, _, _ := parseFile(BrowseOptions{ + PathFromRepoRoot: func() string { + return tt.currentDir + }}, tt.fileArg) + assert.Equal(t, tt.expectedPath, path, tt.name) } } diff --git a/pkg/cmd/codespace/code.go b/pkg/cmd/codespace/code.go new file mode 100644 index 000000000..2c66b4655 --- /dev/null +++ b/pkg/cmd/codespace/code.go @@ -0,0 +1,66 @@ +package codespace + +import ( + "context" + "fmt" + "io/ioutil" + "net/url" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" +) + +type browser interface { + Browse(string) error +} + +func newCodeCmd(app *App) *cobra.Command { + var ( + codespace string + useInsiders bool + ) + + codeCmd := &cobra.Command{ + Use: "code", + Short: "Open a codespace in Visual Studio Code", + Args: noArgsConstraint, + RunE: func(cmd *cobra.Command, args []string) error { + b := cmdutil.NewBrowser("", ioutil.Discard, app.io.ErrOut) + return app.VSCode(cmd.Context(), b, codespace, useInsiders) + }, + } + + codeCmd.Flags().StringVarP(&codespace, "codespace", "c", "", "Name of the codespace") + codeCmd.Flags().BoolVar(&useInsiders, "insiders", false, "Use the insiders version of Visual Studio Code") + + return codeCmd +} + +// VSCode opens a codespace in the local VS VSCode application. +func (a *App) VSCode(ctx context.Context, browser browser, codespaceName string, useInsiders bool) error { + if codespaceName == "" { + codespace, err := chooseCodespace(ctx, a.apiClient) + if err != nil { + if err == errNoCodespaces { + return err + } + return fmt.Errorf("error choosing codespace: %w", err) + } + codespaceName = codespace.Name + } + + url := vscodeProtocolURL(codespaceName, useInsiders) + if err := browser.Browse(url); err != nil { + return fmt.Errorf("error opening Visual Studio Code: %w", err) + } + + return nil +} + +func vscodeProtocolURL(codespaceName string, useInsiders bool) string { + application := "vscode" + if useInsiders { + application = "vscode-insiders" + } + return fmt.Sprintf("%s://github.codespaces/connect?name=%s", application, url.QueryEscape(codespaceName)) +} diff --git a/pkg/cmd/codespace/code_test.go b/pkg/cmd/codespace/code_test.go new file mode 100644 index 000000000..956219bb2 --- /dev/null +++ b/pkg/cmd/codespace/code_test.go @@ -0,0 +1,50 @@ +package codespace + +import ( + "context" + "testing" + + "github.com/cli/cli/v2/pkg/cmdutil" +) + +func TestApp_VSCode(t *testing.T) { + type args struct { + codespaceName string + useInsiders bool + } + tests := []struct { + name string + args args + wantErr bool + wantURL string + }{ + { + name: "open VS Code", + args: args{ + codespaceName: "monalisa-cli-cli-abcdef", + useInsiders: false, + }, + wantErr: false, + wantURL: "vscode://github.codespaces/connect?name=monalisa-cli-cli-abcdef", + }, + { + name: "open VS Code Insiders", + args: args{ + codespaceName: "monalisa-cli-cli-abcdef", + useInsiders: true, + }, + wantErr: false, + wantURL: "vscode-insiders://github.codespaces/connect?name=monalisa-cli-cli-abcdef", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := &cmdutil.TestBrowser{} + a := &App{} + if err := a.VSCode(context.Background(), b, tt.args.codespaceName, tt.args.useInsiders); (err != nil) != tt.wantErr { + t.Errorf("App.VSCode() error = %v, wantErr %v", err, tt.wantErr) + } + b.Verify(t, tt.wantURL) + }) + } +} diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go new file mode 100644 index 000000000..1107ae6a5 --- /dev/null +++ b/pkg/cmd/codespace/common.go @@ -0,0 +1,291 @@ +package codespace + +// This file defines functions common to the entire codespace command set. + +import ( + "context" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "os" + "sort" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" + "golang.org/x/term" +) + +type executable interface { + Executable() string +} + +type App struct { + io *iostreams.IOStreams + apiClient apiClient + errLogger *log.Logger + executable executable +} + +func NewApp(io *iostreams.IOStreams, exe executable, apiClient apiClient) *App { + errLogger := log.New(io.ErrOut, "", 0) + + return &App{ + io: io, + apiClient: apiClient, + errLogger: errLogger, + executable: exe, + } +} + +// StartProgressIndicatorWithLabel starts a progress indicator with a message. +func (a *App) StartProgressIndicatorWithLabel(s string) { + a.io.StartProgressIndicatorWithLabel(s) +} + +// StopProgressIndicator stops the progress indicator. +func (a *App) StopProgressIndicator() { + a.io.StopProgressIndicator() +} + +//go:generate moq -fmt goimports -rm -skip-ensure -out mock_api.go . apiClient +type apiClient interface { + GetUser(ctx context.Context) (*api.User, error) + GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) + ListCodespaces(ctx context.Context, limit int) ([]*api.Codespace, error) + DeleteCodespace(ctx context.Context, name string) error + StartCodespace(ctx context.Context, name string) error + StopCodespace(ctx context.Context, name string) error + CreateCodespace(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) + GetRepository(ctx context.Context, nwo string) (*api.Repository, error) + AuthorizedKeys(ctx context.Context, user string) ([]byte, error) + GetCodespaceRegionLocation(ctx context.Context) (string, error) + GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) + GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) +} + +var errNoCodespaces = errors.New("you have no codespaces") + +func chooseCodespace(ctx context.Context, apiClient apiClient) (*api.Codespace, error) { + codespaces, err := apiClient.ListCodespaces(ctx, -1) + if err != nil { + return nil, fmt.Errorf("error getting codespaces: %w", err) + } + return chooseCodespaceFromList(ctx, codespaces) +} + +// chooseCodespaceFromList returns the selected codespace from the list, +// or an error if there are no codespaces. +func chooseCodespaceFromList(ctx context.Context, codespaces []*api.Codespace) (*api.Codespace, error) { + if len(codespaces) == 0 { + return nil, errNoCodespaces + } + + sort.Slice(codespaces, func(i, j int) bool { + return codespaces[i].CreatedAt > codespaces[j].CreatedAt + }) + + type codespaceWithIndex struct { + cs codespace + idx int + } + + namesWithConflict := make(map[string]bool) + codespacesByName := make(map[string]codespaceWithIndex) + codespacesNames := make([]string, 0, len(codespaces)) + for _, apiCodespace := range codespaces { + cs := codespace{apiCodespace} + csName := cs.displayName(false, false) + displayNameWithGitStatus := cs.displayName(false, true) + + _, hasExistingConflict := namesWithConflict[csName] + if seenCodespace, ok := codespacesByName[csName]; ok || hasExistingConflict { + // There is an existing codespace on the repo and branch. + // We need to disambiguate by adding the codespace name + // to the existing entry and the one we are processing now. + if !hasExistingConflict { + fullDisplayName := seenCodespace.cs.displayName(true, false) + fullDisplayNameWithGitStatus := seenCodespace.cs.displayName(true, true) + + codespacesByName[fullDisplayName] = codespaceWithIndex{seenCodespace.cs, seenCodespace.idx} + codespacesNames[seenCodespace.idx] = fullDisplayNameWithGitStatus + delete(codespacesByName, csName) // delete the existing map entry with old name + + // All other codespaces with the same name should update + // to their specific name, this tracks conflicting names going forward + namesWithConflict[csName] = true + } + + // update this codespace names to include the name to disambiguate + csName = cs.displayName(true, false) + displayNameWithGitStatus = cs.displayName(true, true) + } + + codespacesByName[csName] = codespaceWithIndex{cs, len(codespacesNames)} + codespacesNames = append(codespacesNames, displayNameWithGitStatus) + } + + csSurvey := []*survey.Question{ + { + Name: "codespace", + Prompt: &survey.Select{ + Message: "Choose codespace:", + Options: codespacesNames, + Default: codespacesNames[0], + }, + Validate: survey.Required, + }, + } + + var answers struct { + Codespace string + } + if err := ask(csSurvey, &answers); err != nil { + return nil, fmt.Errorf("error getting answers: %w", err) + } + + // Codespaces are indexed without the git status included as compared + // to how it is displayed in the prompt, so the git status symbol needs + // cleaning up in case it is included. + selectedCodespace := strings.Replace(answers.Codespace, gitStatusDirty, "", -1) + return codespacesByName[selectedCodespace].cs.Codespace, nil +} + +// getOrChooseCodespace prompts the user to choose a codespace if the codespaceName is empty. +// It then fetches the codespace record with full connection details. +// TODO(josebalius): accept a progress indicator or *App and show progress when fetching. +func getOrChooseCodespace(ctx context.Context, apiClient apiClient, codespaceName string) (codespace *api.Codespace, err error) { + if codespaceName == "" { + codespace, err = chooseCodespace(ctx, apiClient) + if err != nil { + if err == errNoCodespaces { + return nil, err + } + return nil, fmt.Errorf("choosing codespace: %w", err) + } + } else { + codespace, err = apiClient.GetCodespace(ctx, codespaceName, true) + if err != nil { + return nil, fmt.Errorf("getting full codespace details: %w", err) + } + } + + return codespace, nil +} + +func safeClose(closer io.Closer, err *error) { + if closeErr := closer.Close(); *err == nil { + *err = closeErr + } +} + +// hasTTY indicates whether the process connected to a terminal. +// It is not portable to assume stdin/stdout are fds 0 and 1. +var hasTTY = term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) + +// ask asks survey questions on the terminal, using standard options. +// It fails unless hasTTY, but ideally callers should avoid calling it in that case. +func ask(qs []*survey.Question, response interface{}) error { + if !hasTTY { + return fmt.Errorf("no terminal") + } + err := survey.Ask(qs, response, survey.WithShowCursor(true)) + // The survey package temporarily clears the terminal's ISIG mode bit + // (see tcsetattr(3)) so the QUIT button (Ctrl-C) is reported as + // ASCII \x03 (ETX) instead of delivering SIGINT to the application. + // So we have to serve ourselves the SIGINT. + // + // https://github.com/AlecAivazis/survey/#why-isnt-ctrl-c-working + if err == terminal.InterruptErr { + self, _ := os.FindProcess(os.Getpid()) + _ = self.Signal(os.Interrupt) // assumes POSIX + + // Suspend the goroutine, to avoid a race between + // return from main and async delivery of INT signal. + select {} + } + return err +} + +// checkAuthorizedKeys reports an error if the user has not registered any SSH keys; +// see https://github.com/cli/cli/v2/issues/166#issuecomment-921769703. +// The check is not required for security but it improves the error message. +func checkAuthorizedKeys(ctx context.Context, client apiClient) error { + user, err := client.GetUser(ctx) + if err != nil { + return fmt.Errorf("error getting user: %w", err) + } + + keys, err := client.AuthorizedKeys(ctx, user.Login) + if err != nil { + return fmt.Errorf("failed to read GitHub-authorized SSH keys for %s: %w", user, err) + } + if len(keys) == 0 { + return fmt.Errorf("user %s has no GitHub-authorized SSH keys", user) + } + return nil // success +} + +var ErrTooManyArgs = errors.New("the command accepts no arguments") + +func noArgsConstraint(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return ErrTooManyArgs + } + return nil +} + +func noopLogger() *log.Logger { + return log.New(ioutil.Discard, "", 0) +} + +type codespace struct { + *api.Codespace +} + +// displayName returns the repository nwo and branch. +// If includeName is true, the name of the codespace is included. +// If includeGitStatus is true, the branch will include a star if +// the codespace has unsaved changes. +func (c codespace) displayName(includeName, includeGitStatus bool) string { + branch := c.GitStatus.Ref + if includeGitStatus { + branch = c.branchWithGitStatus() + } + + if includeName { + return fmt.Sprintf( + "%s: %s [%s]", c.Repository.FullName, branch, c.Name, + ) + } + return c.Repository.FullName + ": " + branch +} + +// gitStatusDirty represents an unsaved changes status. +const gitStatusDirty = "*" + +// branchWithGitStatus returns the branch with a star +// if the branch is currently being worked on. +func (c codespace) branchWithGitStatus() string { + if c.hasUnsavedChanges() { + return c.GitStatus.Ref + gitStatusDirty + } + + return c.GitStatus.Ref +} + +// hasUnsavedChanges returns whether the environment has +// unsaved changes. +func (c codespace) hasUnsavedChanges() bool { + return c.GitStatus.HasUncommitedChanges || c.GitStatus.HasUnpushedChanges +} + +// running returns whether the codespace environment is running. +func (c codespace) running() bool { + return c.State == api.CodespaceStateAvailable +} diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go new file mode 100644 index 000000000..ee9d81fe7 --- /dev/null +++ b/pkg/cmd/codespace/create.go @@ -0,0 +1,277 @@ +package codespace + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/cli/cli/v2/internal/codespaces" + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/spf13/cobra" +) + +type createOptions struct { + repo string + branch string + machine string + showStatus bool + idleTimeout time.Duration +} + +func newCreateCmd(app *App) *cobra.Command { + opts := createOptions{} + + createCmd := &cobra.Command{ + Use: "create", + Short: "Create a codespace", + Args: noArgsConstraint, + RunE: func(cmd *cobra.Command, args []string) error { + return app.Create(cmd.Context(), opts) + }, + } + + createCmd.Flags().StringVarP(&opts.repo, "repo", "r", "", "repository name with owner: user/repo") + createCmd.Flags().StringVarP(&opts.branch, "branch", "b", "", "repository branch") + createCmd.Flags().StringVarP(&opts.machine, "machine", "m", "", "hardware specifications for the VM") + createCmd.Flags().BoolVarP(&opts.showStatus, "status", "s", false, "show status of post-create command and dotfiles") + createCmd.Flags().DurationVar(&opts.idleTimeout, "idle-timeout", 0, "allowed inactivity before codespace is stopped, e.g. \"10m\", \"1h\"") + + return createCmd +} + +// Create creates a new Codespace +func (a *App) Create(ctx context.Context, opts createOptions) error { + locationCh := getLocation(ctx, a.apiClient) + + userInputs := struct { + Repository string + Branch string + }{ + Repository: opts.repo, + Branch: opts.branch, + } + + if userInputs.Repository == "" { + branchPrompt := "Branch (leave blank for default branch):" + if userInputs.Branch != "" { + branchPrompt = "Branch:" + } + questions := []*survey.Question{ + { + Name: "repository", + Prompt: &survey.Input{Message: "Repository:"}, + Validate: survey.Required, + }, + { + Name: "branch", + Prompt: &survey.Input{ + Message: branchPrompt, + Default: userInputs.Branch, + }, + }, + } + if err := ask(questions, &userInputs); err != nil { + return fmt.Errorf("failed to prompt: %w", err) + } + } + + a.StartProgressIndicatorWithLabel("Fetching repository") + repository, err := a.apiClient.GetRepository(ctx, userInputs.Repository) + a.StopProgressIndicator() + if err != nil { + return fmt.Errorf("error getting repository: %w", err) + } + + branch := userInputs.Branch + if branch == "" { + branch = repository.DefaultBranch + } + + locationResult := <-locationCh + if locationResult.Err != nil { + return fmt.Errorf("error getting codespace region location: %w", locationResult.Err) + } + + machine, err := getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, locationResult.Location) + if err != nil { + return fmt.Errorf("error getting machine type: %w", err) + } + if machine == "" { + return errors.New("there are no available machine types for this repository") + } + + a.StartProgressIndicatorWithLabel("Creating codespace") + codespace, err := a.apiClient.CreateCodespace(ctx, &api.CreateCodespaceParams{ + RepositoryID: repository.ID, + Branch: branch, + Machine: machine, + Location: locationResult.Location, + IdleTimeoutMinutes: int(opts.idleTimeout.Minutes()), + }) + a.StopProgressIndicator() + if err != nil { + return fmt.Errorf("error creating codespace: %w", err) + } + + if opts.showStatus { + if err := a.showStatus(ctx, codespace); err != nil { + return fmt.Errorf("show status: %w", err) + } + } + + fmt.Fprintln(a.io.Out, codespace.Name) + return nil +} + +// showStatus polls the codespace for a list of post create states and their status. It will keep polling +// until all states have finished. Once all states have finished, we poll once more to check if any new +// states have been introduced and stop polling otherwise. +func (a *App) showStatus(ctx context.Context, codespace *api.Codespace) error { + var ( + lastState codespaces.PostCreateState + breakNextState bool + ) + + finishedStates := make(map[string]bool) + ctx, stopPolling := context.WithCancel(ctx) + defer stopPolling() + + poller := func(states []codespaces.PostCreateState) { + var inProgress bool + for _, state := range states { + if _, found := finishedStates[state.Name]; found { + continue // skip this state as we've processed it already + } + + if state.Name != lastState.Name { + a.StartProgressIndicatorWithLabel(state.Name) + + if state.Status == codespaces.PostCreateStateRunning { + inProgress = true + lastState = state + break + } + + finishedStates[state.Name] = true + a.StopProgressIndicator() + } else { + if state.Status == codespaces.PostCreateStateRunning { + inProgress = true + break + } + + finishedStates[state.Name] = true + a.StopProgressIndicator() + lastState = codespaces.PostCreateState{} // reset the value + } + } + + if !inProgress { + if breakNextState { + stopPolling() + return + } + breakNextState = true + } + } + + err := codespaces.PollPostCreateStates(ctx, a, a.apiClient, codespace, poller) + if err != nil { + if errors.Is(err, context.Canceled) && breakNextState { + return nil // we cancelled the context to stop polling, we can ignore the error + } + + return fmt.Errorf("failed to poll state changes from codespace: %w", err) + } + + return nil +} + +type locationResult struct { + Location string + Err error +} + +// getLocation fetches the closest Codespace datacenter region/location to the user. +func getLocation(ctx context.Context, apiClient apiClient) <-chan locationResult { + ch := make(chan locationResult, 1) + go func() { + location, err := apiClient.GetCodespaceRegionLocation(ctx) + ch <- locationResult{location, err} + }() + return ch +} + +// getMachineName prompts the user to select the machine type, or validates the machine if non-empty. +func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machine, branch, location string) (string, error) { + machines, err := apiClient.GetCodespacesMachines(ctx, repoID, branch, location) + if err != nil { + return "", fmt.Errorf("error requesting machine instance types: %w", err) + } + + // if user supplied a machine type, it must be valid + // if no machine type was supplied, we don't error if there are no machine types for the current repo + if machine != "" { + for _, m := range machines { + if machine == m.Name { + return machine, nil + } + } + + availableMachines := make([]string, len(machines)) + for i := 0; i < len(machines); i++ { + availableMachines[i] = machines[i].Name + } + + return "", fmt.Errorf("there is no such machine for the repository: %s\nAvailable machines: %v", machine, availableMachines) + } else if len(machines) == 0 { + return "", nil + } + + if len(machines) == 1 { + // VS Code does not prompt for machine if there is only one, this makes us consistent with that behavior + return machines[0].Name, nil + } + + machineNames := make([]string, 0, len(machines)) + machineByName := make(map[string]*api.Machine) + for _, m := range machines { + machineName := buildDisplayName(m.DisplayName, m.PrebuildAvailability) + machineNames = append(machineNames, machineName) + machineByName[machineName] = m + } + + machineSurvey := []*survey.Question{ + { + Name: "machine", + Prompt: &survey.Select{ + Message: "Choose Machine Type:", + Options: machineNames, + Default: machineNames[0], + }, + Validate: survey.Required, + }, + } + + var machineAnswers struct{ Machine string } + if err := ask(machineSurvey, &machineAnswers); err != nil { + return "", fmt.Errorf("error getting machine: %w", err) + } + + selectedMachine := machineByName[machineAnswers.Machine] + + return selectedMachine.Name, nil +} + +// buildDisplayName returns display name to be used in the machine survey prompt. +func buildDisplayName(displayName string, prebuildAvailability string) string { + prebuildText := "" + + if prebuildAvailability == "blob" || prebuildAvailability == "pool" { + prebuildText = " (Prebuild ready)" + } + + return fmt.Sprintf("%s%s", displayName, prebuildText) +} diff --git a/pkg/cmd/codespace/create_test.go b/pkg/cmd/codespace/create_test.go new file mode 100644 index 000000000..4b266ffaa --- /dev/null +++ b/pkg/cmd/codespace/create_test.go @@ -0,0 +1,126 @@ +package codespace + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/iostreams" +) + +func TestApp_Create(t *testing.T) { + type fields struct { + apiClient apiClient + } + tests := []struct { + name string + fields fields + opts createOptions + wantErr bool + wantStdout string + wantStderr string + }{ + { + name: "create codespace with default branch and 30m idle timeout", + fields: fields{ + apiClient: &apiClientMock{ + GetCodespaceRegionLocationFunc: func(ctx context.Context) (string, error) { + return "EUROPE", nil + }, + GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) { + return &api.Repository{ + ID: 1234, + FullName: nwo, + DefaultBranch: "main", + }, nil + }, + GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) { + return []*api.Machine{ + { + Name: "GIGA", + DisplayName: "Gigabits of a machine", + }, + }, nil + }, + CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) { + if params.Branch != "main" { + return nil, fmt.Errorf("got branch %q, want %q", params.Branch, "main") + } + if params.IdleTimeoutMinutes != 30 { + return nil, fmt.Errorf("idle timeout minutes was %v", params.IdleTimeoutMinutes) + } + return &api.Codespace{ + Name: "monalisa-dotfiles-abcd1234", + }, nil + }, + }, + }, + opts: createOptions{ + repo: "monalisa/dotfiles", + branch: "", + machine: "GIGA", + showStatus: false, + idleTimeout: 30 * time.Minute, + }, + wantStdout: "monalisa-dotfiles-abcd1234\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + a := &App{ + io: io, + apiClient: tt.fields.apiClient, + } + if err := a.Create(context.Background(), tt.opts); (err != nil) != tt.wantErr { + t.Errorf("App.Create() error = %v, wantErr %v", err, tt.wantErr) + } + if got := stdout.String(); got != tt.wantStdout { + t.Errorf("stdout = %v, want %v", got, tt.wantStdout) + } + if got := stderr.String(); got != tt.wantStderr { + t.Errorf("stderr = %v, want %v", got, tt.wantStderr) + } + }) + } +} + +func TestBuildDisplayName(t *testing.T) { + tests := []struct { + name string + prebuildAvailability string + expectedDisplayName string + }{ + { + name: "prebuild availability is pool", + prebuildAvailability: "pool", + expectedDisplayName: "4 cores, 8 GB RAM, 32 GB storage (Prebuild ready)", + }, + { + name: "prebuild availability is blob", + prebuildAvailability: "blob", + expectedDisplayName: "4 cores, 8 GB RAM, 32 GB storage (Prebuild ready)", + }, + { + name: "prebuild availability is none", + prebuildAvailability: "none", + expectedDisplayName: "4 cores, 8 GB RAM, 32 GB storage", + }, + { + name: "prebuild availability is empty", + prebuildAvailability: "", + expectedDisplayName: "4 cores, 8 GB RAM, 32 GB storage", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + displayName := buildDisplayName("4 cores, 8 GB RAM, 32 GB storage", tt.prebuildAvailability) + + if displayName != tt.expectedDisplayName { + t.Errorf("displayName = %q, expectedDisplayName %q", displayName, tt.expectedDisplayName) + } + }) + } +} diff --git a/pkg/cmd/codespace/delete.go b/pkg/cmd/codespace/delete.go new file mode 100644 index 000000000..941475f24 --- /dev/null +++ b/pkg/cmd/codespace/delete.go @@ -0,0 +1,179 @@ +package codespace + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" +) + +type deleteOptions struct { + deleteAll bool + skipConfirm bool + codespaceName string + repoFilter string + keepDays uint16 + + isInteractive bool + now func() time.Time + prompter prompter +} + +//go:generate moq -fmt goimports -rm -skip-ensure -out mock_prompter.go . prompter +type prompter interface { + Confirm(message string) (bool, error) +} + +func newDeleteCmd(app *App) *cobra.Command { + opts := deleteOptions{ + isInteractive: hasTTY, + now: time.Now, + prompter: &surveyPrompter{}, + } + + deleteCmd := &cobra.Command{ + Use: "delete", + Short: "Delete a codespace", + Args: noArgsConstraint, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.deleteAll && opts.repoFilter != "" { + return errors.New("both --all and --repo is not supported") + } + return app.Delete(cmd.Context(), opts) + }, + } + + deleteCmd.Flags().StringVarP(&opts.codespaceName, "codespace", "c", "", "Name of the codespace") + deleteCmd.Flags().BoolVar(&opts.deleteAll, "all", false, "Delete all codespaces") + deleteCmd.Flags().StringVarP(&opts.repoFilter, "repo", "r", "", "Delete codespaces for a `repository`") + deleteCmd.Flags().BoolVarP(&opts.skipConfirm, "force", "f", false, "Skip confirmation for codespaces that contain unsaved changes") + deleteCmd.Flags().Uint16Var(&opts.keepDays, "days", 0, "Delete codespaces older than `N` days") + + return deleteCmd +} + +func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) { + var codespaces []*api.Codespace + nameFilter := opts.codespaceName + if nameFilter == "" { + a.StartProgressIndicatorWithLabel("Fetching codespaces") + codespaces, err = a.apiClient.ListCodespaces(ctx, -1) + a.StopProgressIndicator() + if err != nil { + return fmt.Errorf("error getting codespaces: %w", err) + } + + if !opts.deleteAll && opts.repoFilter == "" { + c, err := chooseCodespaceFromList(ctx, codespaces) + if err != nil { + return fmt.Errorf("error choosing codespace: %w", err) + } + nameFilter = c.Name + } + } else { + a.StartProgressIndicatorWithLabel("Fetching codespace") + codespace, err := a.apiClient.GetCodespace(ctx, nameFilter, false) + a.StopProgressIndicator() + if err != nil { + return fmt.Errorf("error fetching codespace information: %w", err) + } + + codespaces = []*api.Codespace{codespace} + } + + codespacesToDelete := make([]*api.Codespace, 0, len(codespaces)) + lastUpdatedCutoffTime := opts.now().AddDate(0, 0, -int(opts.keepDays)) + for _, c := range codespaces { + if nameFilter != "" && c.Name != nameFilter { + continue + } + if opts.repoFilter != "" && !strings.EqualFold(c.Repository.FullName, opts.repoFilter) { + continue + } + if opts.keepDays > 0 { + t, err := time.Parse(time.RFC3339, c.LastUsedAt) + if err != nil { + return fmt.Errorf("error parsing last_used_at timestamp %q: %w", c.LastUsedAt, err) + } + if t.After(lastUpdatedCutoffTime) { + continue + } + } + if !opts.skipConfirm { + confirmed, err := confirmDeletion(opts.prompter, c, opts.isInteractive) + if err != nil { + return fmt.Errorf("unable to confirm: %w", err) + } + if !confirmed { + continue + } + } + codespacesToDelete = append(codespacesToDelete, c) + } + + if len(codespacesToDelete) == 0 { + return errors.New("no codespaces to delete") + } + + progressLabel := "Deleting codespace" + if len(codespacesToDelete) > 1 { + progressLabel = "Deleting codespaces" + } + a.StartProgressIndicatorWithLabel(progressLabel) + defer a.StopProgressIndicator() + + var g errgroup.Group + for _, c := range codespacesToDelete { + codespaceName := c.Name + g.Go(func() error { + if err := a.apiClient.DeleteCodespace(ctx, codespaceName); err != nil { + a.errLogger.Printf("error deleting codespace %q: %v\n", codespaceName, err) + return err + } + return nil + }) + } + + if err := g.Wait(); err != nil { + return errors.New("some codespaces failed to delete") + } + return nil +} + +func confirmDeletion(p prompter, apiCodespace *api.Codespace, isInteractive bool) (bool, error) { + cs := codespace{apiCodespace} + if !cs.hasUnsavedChanges() { + return true, nil + } + if !isInteractive { + return false, fmt.Errorf("codespace %s has unsaved changes (use --force to override)", cs.Name) + } + return p.Confirm(fmt.Sprintf("Codespace %s has unsaved changes. OK to delete?", cs.Name)) +} + +type surveyPrompter struct{} + +func (p *surveyPrompter) Confirm(message string) (bool, error) { + var confirmed struct { + Confirmed bool + } + q := []*survey.Question{ + { + Name: "confirmed", + Prompt: &survey.Confirm{ + Message: message, + }, + }, + } + if err := ask(q, &confirmed); err != nil { + return false, fmt.Errorf("failed to prompt: %w", err) + } + + return confirmed.Confirmed, nil +} diff --git a/pkg/cmd/codespace/delete_test.go b/pkg/cmd/codespace/delete_test.go new file mode 100644 index 000000000..89318b5e3 --- /dev/null +++ b/pkg/cmd/codespace/delete_test.go @@ -0,0 +1,237 @@ +package codespace + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/iostreams" +) + +func TestDelete(t *testing.T) { + user := &api.User{Login: "hubot"} + now, _ := time.Parse(time.RFC3339, "2021-09-22T00:00:00Z") + daysAgo := func(n int) string { + return now.Add(time.Hour * -time.Duration(24*n)).Format(time.RFC3339) + } + + tests := []struct { + name string + opts deleteOptions + codespaces []*api.Codespace + confirms map[string]bool + deleteErr error + wantErr bool + wantDeleted []string + wantStdout string + wantStderr string + }{ + { + name: "by name", + opts: deleteOptions{ + codespaceName: "hubot-robawt-abc", + }, + codespaces: []*api.Codespace{ + { + Name: "hubot-robawt-abc", + }, + }, + wantDeleted: []string{"hubot-robawt-abc"}, + wantStdout: "", + }, + { + name: "by repo", + opts: deleteOptions{ + repoFilter: "monalisa/spoon-knife", + }, + codespaces: []*api.Codespace{ + { + Name: "monalisa-spoonknife-123", + Repository: api.Repository{ + FullName: "monalisa/Spoon-Knife", + }, + }, + { + Name: "hubot-robawt-abc", + Repository: api.Repository{ + FullName: "hubot/ROBAWT", + }, + }, + { + Name: "monalisa-spoonknife-c4f3", + Repository: api.Repository{ + FullName: "monalisa/Spoon-Knife", + }, + }, + }, + wantDeleted: []string{"monalisa-spoonknife-123", "monalisa-spoonknife-c4f3"}, + wantStdout: "", + }, + { + name: "unused", + opts: deleteOptions{ + deleteAll: true, + keepDays: 3, + }, + codespaces: []*api.Codespace{ + { + Name: "monalisa-spoonknife-123", + LastUsedAt: daysAgo(1), + }, + { + Name: "hubot-robawt-abc", + LastUsedAt: daysAgo(4), + }, + { + Name: "monalisa-spoonknife-c4f3", + LastUsedAt: daysAgo(10), + }, + }, + wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"}, + wantStdout: "", + }, + { + name: "deletion failed", + opts: deleteOptions{ + deleteAll: true, + }, + codespaces: []*api.Codespace{ + { + Name: "monalisa-spoonknife-123", + }, + { + Name: "hubot-robawt-abc", + }, + }, + deleteErr: errors.New("aborted by test"), + wantErr: true, + wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-123"}, + wantStderr: heredoc.Doc(` + error deleting codespace "hubot-robawt-abc": aborted by test + error deleting codespace "monalisa-spoonknife-123": aborted by test + `), + }, + { + name: "with confirm", + opts: deleteOptions{ + isInteractive: true, + deleteAll: true, + skipConfirm: false, + }, + codespaces: []*api.Codespace{ + { + Name: "monalisa-spoonknife-123", + GitStatus: api.CodespaceGitStatus{ + HasUnpushedChanges: true, + }, + }, + { + Name: "hubot-robawt-abc", + GitStatus: api.CodespaceGitStatus{ + HasUncommitedChanges: true, + }, + }, + { + Name: "monalisa-spoonknife-c4f3", + GitStatus: api.CodespaceGitStatus{ + HasUnpushedChanges: false, + HasUncommitedChanges: false, + }, + }, + }, + confirms: map[string]bool{ + "Codespace monalisa-spoonknife-123 has unsaved changes. OK to delete?": false, + "Codespace hubot-robawt-abc has unsaved changes. OK to delete?": true, + }, + wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"}, + wantStdout: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiMock := &apiClientMock{ + GetUserFunc: func(_ context.Context) (*api.User, error) { + return user, nil + }, + DeleteCodespaceFunc: func(_ context.Context, name string) error { + if tt.deleteErr != nil { + return tt.deleteErr + } + return nil + }, + } + if tt.opts.codespaceName == "" { + apiMock.ListCodespacesFunc = func(_ context.Context, num int) ([]*api.Codespace, error) { + return tt.codespaces, nil + } + } else { + apiMock.GetCodespaceFunc = func(_ context.Context, name string, includeConnection bool) (*api.Codespace, error) { + return tt.codespaces[0], nil + } + } + opts := tt.opts + opts.now = func() time.Time { return now } + opts.prompter = &prompterMock{ + ConfirmFunc: func(msg string) (bool, error) { + res, found := tt.confirms[msg] + if !found { + return false, fmt.Errorf("unexpected prompt %q", msg) + } + return res, nil + }, + } + + io, _, stdout, stderr := iostreams.Test() + io.SetStdinTTY(true) + io.SetStdoutTTY(true) + app := NewApp(io, nil, apiMock) + err := app.Delete(context.Background(), opts) + if (err != nil) != tt.wantErr { + t.Errorf("delete() error = %v, wantErr %v", err, tt.wantErr) + } + var gotDeleted []string + for _, delArgs := range apiMock.DeleteCodespaceCalls() { + gotDeleted = append(gotDeleted, delArgs.Name) + } + sort.Strings(gotDeleted) + if !sliceEquals(gotDeleted, tt.wantDeleted) { + t.Errorf("deleted %q, want %q", gotDeleted, tt.wantDeleted) + } + if out := stdout.String(); out != tt.wantStdout { + t.Errorf("stdout = %q, want %q", out, tt.wantStdout) + } + if out := sortLines(stderr.String()); out != tt.wantStderr { + t.Errorf("stderr = %q, want %q", out, tt.wantStderr) + } + }) + } +} + +func sliceEquals(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func sortLines(s string) string { + trailing := "" + if strings.HasSuffix(s, "\n") { + s = strings.TrimSuffix(s, "\n") + trailing = "\n" + } + lines := strings.Split(s, "\n") + sort.Strings(lines) + return strings.Join(lines, "\n") + trailing +} diff --git a/pkg/cmd/codespace/list.go b/pkg/cmd/codespace/list.go new file mode 100644 index 000000000..7fe71d6fa --- /dev/null +++ b/pkg/cmd/codespace/list.go @@ -0,0 +1,94 @@ +package codespace + +import ( + "context" + "fmt" + "time" + + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/utils" + "github.com/spf13/cobra" +) + +func newListCmd(app *App) *cobra.Command { + var limit int + var exporter cmdutil.Exporter + + listCmd := &cobra.Command{ + Use: "list", + Short: "List your codespaces", + Args: noArgsConstraint, + RunE: func(cmd *cobra.Command, args []string) error { + if limit < 1 { + return cmdutil.FlagErrorf("invalid limit: %v", limit) + } + + return app.List(cmd.Context(), limit, exporter) + }, + } + + listCmd.Flags().IntVarP(&limit, "limit", "L", 30, "Maximum number of codespaces to list") + cmdutil.AddJSONFlags(listCmd, &exporter, api.CodespaceFields) + + return listCmd +} + +func (a *App) List(ctx context.Context, limit int, exporter cmdutil.Exporter) error { + a.StartProgressIndicatorWithLabel("Fetching codespaces") + codespaces, err := a.apiClient.ListCodespaces(ctx, limit) + a.StopProgressIndicator() + if err != nil { + return fmt.Errorf("error getting codespaces: %w", err) + } + + if err := a.io.StartPager(); err != nil { + a.errLogger.Printf("error starting pager: %v", err) + } + defer a.io.StopPager() + + if exporter != nil { + return exporter.Write(a.io, codespaces) + } + + tp := utils.NewTablePrinter(a.io) + if tp.IsTTY() { + tp.AddField("NAME", nil, nil) + tp.AddField("REPOSITORY", nil, nil) + tp.AddField("BRANCH", nil, nil) + tp.AddField("STATE", nil, nil) + tp.AddField("CREATED AT", nil, nil) + tp.EndRow() + } + + cs := a.io.ColorScheme() + for _, apiCodespace := range codespaces { + c := codespace{apiCodespace} + + var stateColor func(string) string + switch c.State { + case api.CodespaceStateStarting: + stateColor = cs.Yellow + case api.CodespaceStateAvailable: + stateColor = cs.Green + } + + tp.AddField(c.Name, nil, cs.Yellow) + tp.AddField(c.Repository.FullName, nil, nil) + tp.AddField(c.branchWithGitStatus(), nil, cs.Cyan) + tp.AddField(c.State, nil, stateColor) + + if tp.IsTTY() { + ct, err := time.Parse(time.RFC3339, c.CreatedAt) + if err != nil { + return fmt.Errorf("error parsing date %q: %w", c.CreatedAt, err) + } + tp.AddField(utils.FuzzyAgoAbbr(time.Now(), ct), nil, cs.Gray) + } else { + tp.AddField(c.CreatedAt, nil, nil) + } + tp.EndRow() + } + + return tp.Render() +} diff --git a/pkg/cmd/codespace/logs.go b/pkg/cmd/codespace/logs.go new file mode 100644 index 000000000..d0a0c233b --- /dev/null +++ b/pkg/cmd/codespace/logs.go @@ -0,0 +1,108 @@ +package codespace + +import ( + "context" + "fmt" + "net" + + "github.com/cli/cli/v2/internal/codespaces" + "github.com/cli/cli/v2/pkg/liveshare" + "github.com/spf13/cobra" +) + +func newLogsCmd(app *App) *cobra.Command { + var ( + codespace string + follow bool + ) + + logsCmd := &cobra.Command{ + Use: "logs", + Short: "Access codespace logs", + Args: noArgsConstraint, + RunE: func(cmd *cobra.Command, args []string) error { + return app.Logs(cmd.Context(), codespace, follow) + }, + } + + logsCmd.Flags().StringVarP(&codespace, "codespace", "c", "", "Name of the codespace") + logsCmd.Flags().BoolVarP(&follow, "follow", "f", false, "Tail and follow the logs") + + return logsCmd +} + +func (a *App) Logs(ctx context.Context, codespaceName string, follow bool) (err error) { + // Ensure all child tasks (port forwarding, remote exec) terminate before return. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName) + if err != nil { + return fmt.Errorf("get or choose codespace: %w", err) + } + + authkeys := make(chan error, 1) + go func() { + authkeys <- checkAuthorizedKeys(ctx, a.apiClient) + }() + + session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace) + if err != nil { + return fmt.Errorf("connecting to codespace: %w", err) + } + defer safeClose(session, &err) + + if err := <-authkeys; err != nil { + return err + } + + // Ensure local port is listening before client (getPostCreateOutput) connects. + listen, err := net.Listen("tcp", "127.0.0.1:0") // arbitrary port + if err != nil { + return err + } + defer listen.Close() + localPort := listen.Addr().(*net.TCPAddr).Port + + a.StartProgressIndicatorWithLabel("Fetching SSH Details") + remoteSSHServerPort, sshUser, err := session.StartSSHServer(ctx) + a.StopProgressIndicator() + if err != nil { + return fmt.Errorf("error getting ssh server details: %w", err) + } + + cmdType := "cat" + if follow { + cmdType = "tail -f" + } + + dst := fmt.Sprintf("%s@localhost", sshUser) + cmd, err := codespaces.NewRemoteCommand( + ctx, localPort, dst, fmt.Sprintf("%s /workspaces/.codespaces/.persistedshare/creation.log", cmdType), + ) + if err != nil { + return fmt.Errorf("remote command: %w", err) + } + + tunnelClosed := make(chan error, 1) + go func() { + fwd := liveshare.NewPortForwarder(session, "sshd", remoteSSHServerPort, false) + tunnelClosed <- fwd.ForwardToListener(ctx, listen) // error is non-nil + }() + + cmdDone := make(chan error, 1) + go func() { + cmdDone <- cmd.Run() + }() + + select { + case err := <-tunnelClosed: + return fmt.Errorf("connection closed: %w", err) + case err := <-cmdDone: + if err != nil { + return fmt.Errorf("error retrieving logs: %w", err) + } + + return nil // success + } +} diff --git a/pkg/cmd/codespace/mock_api.go b/pkg/cmd/codespace/mock_api.go new file mode 100644 index 000000000..8d40934da --- /dev/null +++ b/pkg/cmd/codespace/mock_api.go @@ -0,0 +1,629 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package codespace + +import ( + "context" + "sync" + + "github.com/cli/cli/v2/internal/codespaces/api" +) + +// apiClientMock is a mock implementation of apiClient. +// +// func TestSomethingThatUsesapiClient(t *testing.T) { +// +// // make and configure a mocked apiClient +// mockedapiClient := &apiClientMock{ +// AuthorizedKeysFunc: func(ctx context.Context, user string) ([]byte, error) { +// panic("mock out the AuthorizedKeys method") +// }, +// CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) { +// panic("mock out the CreateCodespace method") +// }, +// DeleteCodespaceFunc: func(ctx context.Context, name string) error { +// panic("mock out the DeleteCodespace method") +// }, +// GetCodespaceFunc: func(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) { +// panic("mock out the GetCodespace method") +// }, +// GetCodespaceRegionLocationFunc: func(ctx context.Context) (string, error) { +// panic("mock out the GetCodespaceRegionLocation method") +// }, +// GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) { +// panic("mock out the GetCodespaceRepositoryContents method") +// }, +// GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error) { +// panic("mock out the GetCodespacesMachines method") +// }, +// GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) { +// panic("mock out the GetRepository method") +// }, +// GetUserFunc: func(ctx context.Context) (*api.User, error) { +// panic("mock out the GetUser method") +// }, +// ListCodespacesFunc: func(ctx context.Context, limit int) ([]*api.Codespace, error) { +// panic("mock out the ListCodespaces method") +// }, +// StartCodespaceFunc: func(ctx context.Context, name string) error { +// panic("mock out the StartCodespace method") +// }, +// StopCodespaceFunc: func(ctx context.Context, name string) error { +// panic("mock out the StopCodespace method") +// }, +// } +// +// // use mockedapiClient in code that requires apiClient +// // and then make assertions. +// +// } +type apiClientMock struct { + // AuthorizedKeysFunc mocks the AuthorizedKeys method. + AuthorizedKeysFunc func(ctx context.Context, user string) ([]byte, error) + + // CreateCodespaceFunc mocks the CreateCodespace method. + CreateCodespaceFunc func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) + + // DeleteCodespaceFunc mocks the DeleteCodespace method. + DeleteCodespaceFunc func(ctx context.Context, name string) error + + // GetCodespaceFunc mocks the GetCodespace method. + GetCodespaceFunc func(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) + + // GetCodespaceRegionLocationFunc mocks the GetCodespaceRegionLocation method. + GetCodespaceRegionLocationFunc func(ctx context.Context) (string, error) + + // GetCodespaceRepositoryContentsFunc mocks the GetCodespaceRepositoryContents method. + GetCodespaceRepositoryContentsFunc func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) + + // GetCodespacesMachinesFunc mocks the GetCodespacesMachines method. + GetCodespacesMachinesFunc func(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error) + + // GetRepositoryFunc mocks the GetRepository method. + GetRepositoryFunc func(ctx context.Context, nwo string) (*api.Repository, error) + + // GetUserFunc mocks the GetUser method. + GetUserFunc func(ctx context.Context) (*api.User, error) + + // ListCodespacesFunc mocks the ListCodespaces method. + ListCodespacesFunc func(ctx context.Context, limit int) ([]*api.Codespace, error) + + // StartCodespaceFunc mocks the StartCodespace method. + StartCodespaceFunc func(ctx context.Context, name string) error + + // StopCodespaceFunc mocks the StopCodespace method. + StopCodespaceFunc func(ctx context.Context, name string) error + + // calls tracks calls to the methods. + calls struct { + // AuthorizedKeys holds details about calls to the AuthorizedKeys method. + AuthorizedKeys []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // User is the user argument value. + User string + } + // CreateCodespace holds details about calls to the CreateCodespace method. + CreateCodespace []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Params is the params argument value. + Params *api.CreateCodespaceParams + } + // DeleteCodespace holds details about calls to the DeleteCodespace method. + DeleteCodespace []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Name is the name argument value. + Name string + } + // GetCodespace holds details about calls to the GetCodespace method. + GetCodespace []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Name is the name argument value. + Name string + // IncludeConnection is the includeConnection argument value. + IncludeConnection bool + } + // GetCodespaceRegionLocation holds details about calls to the GetCodespaceRegionLocation method. + GetCodespaceRegionLocation []struct { + // Ctx is the ctx argument value. + Ctx context.Context + } + // GetCodespaceRepositoryContents holds details about calls to the GetCodespaceRepositoryContents method. + GetCodespaceRepositoryContents []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Codespace is the codespace argument value. + Codespace *api.Codespace + // Path is the path argument value. + Path string + } + // GetCodespacesMachines holds details about calls to the GetCodespacesMachines method. + GetCodespacesMachines []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // RepoID is the repoID argument value. + RepoID int + // Branch is the branch argument value. + Branch string + // Location is the location argument value. + Location string + } + // GetRepository holds details about calls to the GetRepository method. + GetRepository []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Nwo is the nwo argument value. + Nwo string + } + // GetUser holds details about calls to the GetUser method. + GetUser []struct { + // Ctx is the ctx argument value. + Ctx context.Context + } + // ListCodespaces holds details about calls to the ListCodespaces method. + ListCodespaces []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Limit is the limit argument value. + Limit int + } + // StartCodespace holds details about calls to the StartCodespace method. + StartCodespace []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Name is the name argument value. + Name string + } + // StopCodespace holds details about calls to the StopCodespace method. + StopCodespace []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // Name is the name argument value. + Name string + } + } + lockAuthorizedKeys sync.RWMutex + lockCreateCodespace sync.RWMutex + lockDeleteCodespace sync.RWMutex + lockGetCodespace sync.RWMutex + lockGetCodespaceRegionLocation sync.RWMutex + lockGetCodespaceRepositoryContents sync.RWMutex + lockGetCodespacesMachines sync.RWMutex + lockGetRepository sync.RWMutex + lockGetUser sync.RWMutex + lockListCodespaces sync.RWMutex + lockStartCodespace sync.RWMutex + lockStopCodespace sync.RWMutex +} + +// AuthorizedKeys calls AuthorizedKeysFunc. +func (mock *apiClientMock) AuthorizedKeys(ctx context.Context, user string) ([]byte, error) { + if mock.AuthorizedKeysFunc == nil { + panic("apiClientMock.AuthorizedKeysFunc: method is nil but apiClient.AuthorizedKeys was just called") + } + callInfo := struct { + Ctx context.Context + User string + }{ + Ctx: ctx, + User: user, + } + mock.lockAuthorizedKeys.Lock() + mock.calls.AuthorizedKeys = append(mock.calls.AuthorizedKeys, callInfo) + mock.lockAuthorizedKeys.Unlock() + return mock.AuthorizedKeysFunc(ctx, user) +} + +// AuthorizedKeysCalls gets all the calls that were made to AuthorizedKeys. +// Check the length with: +// len(mockedapiClient.AuthorizedKeysCalls()) +func (mock *apiClientMock) AuthorizedKeysCalls() []struct { + Ctx context.Context + User string +} { + var calls []struct { + Ctx context.Context + User string + } + mock.lockAuthorizedKeys.RLock() + calls = mock.calls.AuthorizedKeys + mock.lockAuthorizedKeys.RUnlock() + return calls +} + +// CreateCodespace calls CreateCodespaceFunc. +func (mock *apiClientMock) CreateCodespace(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) { + if mock.CreateCodespaceFunc == nil { + panic("apiClientMock.CreateCodespaceFunc: method is nil but apiClient.CreateCodespace was just called") + } + callInfo := struct { + Ctx context.Context + Params *api.CreateCodespaceParams + }{ + Ctx: ctx, + Params: params, + } + mock.lockCreateCodespace.Lock() + mock.calls.CreateCodespace = append(mock.calls.CreateCodespace, callInfo) + mock.lockCreateCodespace.Unlock() + return mock.CreateCodespaceFunc(ctx, params) +} + +// CreateCodespaceCalls gets all the calls that were made to CreateCodespace. +// Check the length with: +// len(mockedapiClient.CreateCodespaceCalls()) +func (mock *apiClientMock) CreateCodespaceCalls() []struct { + Ctx context.Context + Params *api.CreateCodespaceParams +} { + var calls []struct { + Ctx context.Context + Params *api.CreateCodespaceParams + } + mock.lockCreateCodespace.RLock() + calls = mock.calls.CreateCodespace + mock.lockCreateCodespace.RUnlock() + return calls +} + +// DeleteCodespace calls DeleteCodespaceFunc. +func (mock *apiClientMock) DeleteCodespace(ctx context.Context, name string) error { + if mock.DeleteCodespaceFunc == nil { + panic("apiClientMock.DeleteCodespaceFunc: method is nil but apiClient.DeleteCodespace was just called") + } + callInfo := struct { + Ctx context.Context + Name string + }{ + Ctx: ctx, + Name: name, + } + mock.lockDeleteCodespace.Lock() + mock.calls.DeleteCodespace = append(mock.calls.DeleteCodespace, callInfo) + mock.lockDeleteCodespace.Unlock() + return mock.DeleteCodespaceFunc(ctx, name) +} + +// DeleteCodespaceCalls gets all the calls that were made to DeleteCodespace. +// Check the length with: +// len(mockedapiClient.DeleteCodespaceCalls()) +func (mock *apiClientMock) DeleteCodespaceCalls() []struct { + Ctx context.Context + Name string +} { + var calls []struct { + Ctx context.Context + Name string + } + mock.lockDeleteCodespace.RLock() + calls = mock.calls.DeleteCodespace + mock.lockDeleteCodespace.RUnlock() + return calls +} + +// GetCodespace calls GetCodespaceFunc. +func (mock *apiClientMock) GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) { + if mock.GetCodespaceFunc == nil { + panic("apiClientMock.GetCodespaceFunc: method is nil but apiClient.GetCodespace was just called") + } + callInfo := struct { + Ctx context.Context + Name string + IncludeConnection bool + }{ + Ctx: ctx, + Name: name, + IncludeConnection: includeConnection, + } + mock.lockGetCodespace.Lock() + mock.calls.GetCodespace = append(mock.calls.GetCodespace, callInfo) + mock.lockGetCodespace.Unlock() + return mock.GetCodespaceFunc(ctx, name, includeConnection) +} + +// GetCodespaceCalls gets all the calls that were made to GetCodespace. +// Check the length with: +// len(mockedapiClient.GetCodespaceCalls()) +func (mock *apiClientMock) GetCodespaceCalls() []struct { + Ctx context.Context + Name string + IncludeConnection bool +} { + var calls []struct { + Ctx context.Context + Name string + IncludeConnection bool + } + mock.lockGetCodespace.RLock() + calls = mock.calls.GetCodespace + mock.lockGetCodespace.RUnlock() + return calls +} + +// GetCodespaceRegionLocation calls GetCodespaceRegionLocationFunc. +func (mock *apiClientMock) GetCodespaceRegionLocation(ctx context.Context) (string, error) { + if mock.GetCodespaceRegionLocationFunc == nil { + panic("apiClientMock.GetCodespaceRegionLocationFunc: method is nil but apiClient.GetCodespaceRegionLocation was just called") + } + callInfo := struct { + Ctx context.Context + }{ + Ctx: ctx, + } + mock.lockGetCodespaceRegionLocation.Lock() + mock.calls.GetCodespaceRegionLocation = append(mock.calls.GetCodespaceRegionLocation, callInfo) + mock.lockGetCodespaceRegionLocation.Unlock() + return mock.GetCodespaceRegionLocationFunc(ctx) +} + +// GetCodespaceRegionLocationCalls gets all the calls that were made to GetCodespaceRegionLocation. +// Check the length with: +// len(mockedapiClient.GetCodespaceRegionLocationCalls()) +func (mock *apiClientMock) GetCodespaceRegionLocationCalls() []struct { + Ctx context.Context +} { + var calls []struct { + Ctx context.Context + } + mock.lockGetCodespaceRegionLocation.RLock() + calls = mock.calls.GetCodespaceRegionLocation + mock.lockGetCodespaceRegionLocation.RUnlock() + return calls +} + +// GetCodespaceRepositoryContents calls GetCodespaceRepositoryContentsFunc. +func (mock *apiClientMock) GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) { + if mock.GetCodespaceRepositoryContentsFunc == nil { + panic("apiClientMock.GetCodespaceRepositoryContentsFunc: method is nil but apiClient.GetCodespaceRepositoryContents was just called") + } + callInfo := struct { + Ctx context.Context + Codespace *api.Codespace + Path string + }{ + Ctx: ctx, + Codespace: codespace, + Path: path, + } + mock.lockGetCodespaceRepositoryContents.Lock() + mock.calls.GetCodespaceRepositoryContents = append(mock.calls.GetCodespaceRepositoryContents, callInfo) + mock.lockGetCodespaceRepositoryContents.Unlock() + return mock.GetCodespaceRepositoryContentsFunc(ctx, codespace, path) +} + +// GetCodespaceRepositoryContentsCalls gets all the calls that were made to GetCodespaceRepositoryContents. +// Check the length with: +// len(mockedapiClient.GetCodespaceRepositoryContentsCalls()) +func (mock *apiClientMock) GetCodespaceRepositoryContentsCalls() []struct { + Ctx context.Context + Codespace *api.Codespace + Path string +} { + var calls []struct { + Ctx context.Context + Codespace *api.Codespace + Path string + } + mock.lockGetCodespaceRepositoryContents.RLock() + calls = mock.calls.GetCodespaceRepositoryContents + mock.lockGetCodespaceRepositoryContents.RUnlock() + return calls +} + +// GetCodespacesMachines calls GetCodespacesMachinesFunc. +func (mock *apiClientMock) GetCodespacesMachines(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error) { + if mock.GetCodespacesMachinesFunc == nil { + panic("apiClientMock.GetCodespacesMachinesFunc: method is nil but apiClient.GetCodespacesMachines was just called") + } + callInfo := struct { + Ctx context.Context + RepoID int + Branch string + Location string + }{ + Ctx: ctx, + RepoID: repoID, + Branch: branch, + Location: location, + } + mock.lockGetCodespacesMachines.Lock() + mock.calls.GetCodespacesMachines = append(mock.calls.GetCodespacesMachines, callInfo) + mock.lockGetCodespacesMachines.Unlock() + return mock.GetCodespacesMachinesFunc(ctx, repoID, branch, location) +} + +// GetCodespacesMachinesCalls gets all the calls that were made to GetCodespacesMachines. +// Check the length with: +// len(mockedapiClient.GetCodespacesMachinesCalls()) +func (mock *apiClientMock) GetCodespacesMachinesCalls() []struct { + Ctx context.Context + RepoID int + Branch string + Location string +} { + var calls []struct { + Ctx context.Context + RepoID int + Branch string + Location string + } + mock.lockGetCodespacesMachines.RLock() + calls = mock.calls.GetCodespacesMachines + mock.lockGetCodespacesMachines.RUnlock() + return calls +} + +// GetRepository calls GetRepositoryFunc. +func (mock *apiClientMock) GetRepository(ctx context.Context, nwo string) (*api.Repository, error) { + if mock.GetRepositoryFunc == nil { + panic("apiClientMock.GetRepositoryFunc: method is nil but apiClient.GetRepository was just called") + } + callInfo := struct { + Ctx context.Context + Nwo string + }{ + Ctx: ctx, + Nwo: nwo, + } + mock.lockGetRepository.Lock() + mock.calls.GetRepository = append(mock.calls.GetRepository, callInfo) + mock.lockGetRepository.Unlock() + return mock.GetRepositoryFunc(ctx, nwo) +} + +// GetRepositoryCalls gets all the calls that were made to GetRepository. +// Check the length with: +// len(mockedapiClient.GetRepositoryCalls()) +func (mock *apiClientMock) GetRepositoryCalls() []struct { + Ctx context.Context + Nwo string +} { + var calls []struct { + Ctx context.Context + Nwo string + } + mock.lockGetRepository.RLock() + calls = mock.calls.GetRepository + mock.lockGetRepository.RUnlock() + return calls +} + +// GetUser calls GetUserFunc. +func (mock *apiClientMock) GetUser(ctx context.Context) (*api.User, error) { + if mock.GetUserFunc == nil { + panic("apiClientMock.GetUserFunc: method is nil but apiClient.GetUser was just called") + } + callInfo := struct { + Ctx context.Context + }{ + Ctx: ctx, + } + mock.lockGetUser.Lock() + mock.calls.GetUser = append(mock.calls.GetUser, callInfo) + mock.lockGetUser.Unlock() + return mock.GetUserFunc(ctx) +} + +// GetUserCalls gets all the calls that were made to GetUser. +// Check the length with: +// len(mockedapiClient.GetUserCalls()) +func (mock *apiClientMock) GetUserCalls() []struct { + Ctx context.Context +} { + var calls []struct { + Ctx context.Context + } + mock.lockGetUser.RLock() + calls = mock.calls.GetUser + mock.lockGetUser.RUnlock() + return calls +} + +// ListCodespaces calls ListCodespacesFunc. +func (mock *apiClientMock) ListCodespaces(ctx context.Context, limit int) ([]*api.Codespace, error) { + if mock.ListCodespacesFunc == nil { + panic("apiClientMock.ListCodespacesFunc: method is nil but apiClient.ListCodespaces was just called") + } + callInfo := struct { + Ctx context.Context + Limit int + }{ + Ctx: ctx, + Limit: limit, + } + mock.lockListCodespaces.Lock() + mock.calls.ListCodespaces = append(mock.calls.ListCodespaces, callInfo) + mock.lockListCodespaces.Unlock() + return mock.ListCodespacesFunc(ctx, limit) +} + +// ListCodespacesCalls gets all the calls that were made to ListCodespaces. +// Check the length with: +// len(mockedapiClient.ListCodespacesCalls()) +func (mock *apiClientMock) ListCodespacesCalls() []struct { + Ctx context.Context + Limit int +} { + var calls []struct { + Ctx context.Context + Limit int + } + mock.lockListCodespaces.RLock() + calls = mock.calls.ListCodespaces + mock.lockListCodespaces.RUnlock() + return calls +} + +// StartCodespace calls StartCodespaceFunc. +func (mock *apiClientMock) StartCodespace(ctx context.Context, name string) error { + if mock.StartCodespaceFunc == nil { + panic("apiClientMock.StartCodespaceFunc: method is nil but apiClient.StartCodespace was just called") + } + callInfo := struct { + Ctx context.Context + Name string + }{ + Ctx: ctx, + Name: name, + } + mock.lockStartCodespace.Lock() + mock.calls.StartCodespace = append(mock.calls.StartCodespace, callInfo) + mock.lockStartCodespace.Unlock() + return mock.StartCodespaceFunc(ctx, name) +} + +// StartCodespaceCalls gets all the calls that were made to StartCodespace. +// Check the length with: +// len(mockedapiClient.StartCodespaceCalls()) +func (mock *apiClientMock) StartCodespaceCalls() []struct { + Ctx context.Context + Name string +} { + var calls []struct { + Ctx context.Context + Name string + } + mock.lockStartCodespace.RLock() + calls = mock.calls.StartCodespace + mock.lockStartCodespace.RUnlock() + return calls +} + +// StopCodespace calls StopCodespaceFunc. +func (mock *apiClientMock) StopCodespace(ctx context.Context, name string) error { + if mock.StopCodespaceFunc == nil { + panic("apiClientMock.StopCodespaceFunc: method is nil but apiClient.StopCodespace was just called") + } + callInfo := struct { + Ctx context.Context + Name string + }{ + Ctx: ctx, + Name: name, + } + mock.lockStopCodespace.Lock() + mock.calls.StopCodespace = append(mock.calls.StopCodespace, callInfo) + mock.lockStopCodespace.Unlock() + return mock.StopCodespaceFunc(ctx, name) +} + +// StopCodespaceCalls gets all the calls that were made to StopCodespace. +// Check the length with: +// len(mockedapiClient.StopCodespaceCalls()) +func (mock *apiClientMock) StopCodespaceCalls() []struct { + Ctx context.Context + Name string +} { + var calls []struct { + Ctx context.Context + Name string + } + mock.lockStopCodespace.RLock() + calls = mock.calls.StopCodespace + mock.lockStopCodespace.RUnlock() + return calls +} diff --git a/pkg/cmd/codespace/mock_prompter.go b/pkg/cmd/codespace/mock_prompter.go new file mode 100644 index 000000000..3ce257a39 --- /dev/null +++ b/pkg/cmd/codespace/mock_prompter.go @@ -0,0 +1,69 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package codespace + +import ( + "sync" +) + +// prompterMock is a mock implementation of prompter. +// +// func TestSomethingThatUsesprompter(t *testing.T) { +// +// // make and configure a mocked prompter +// mockedprompter := &prompterMock{ +// ConfirmFunc: func(message string) (bool, error) { +// panic("mock out the Confirm method") +// }, +// } +// +// // use mockedprompter in code that requires prompter +// // and then make assertions. +// +// } +type prompterMock struct { + // ConfirmFunc mocks the Confirm method. + ConfirmFunc func(message string) (bool, error) + + // calls tracks calls to the methods. + calls struct { + // Confirm holds details about calls to the Confirm method. + Confirm []struct { + // Message is the message argument value. + Message string + } + } + lockConfirm sync.RWMutex +} + +// Confirm calls ConfirmFunc. +func (mock *prompterMock) Confirm(message string) (bool, error) { + if mock.ConfirmFunc == nil { + panic("prompterMock.ConfirmFunc: method is nil but prompter.Confirm was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockConfirm.Lock() + mock.calls.Confirm = append(mock.calls.Confirm, callInfo) + mock.lockConfirm.Unlock() + return mock.ConfirmFunc(message) +} + +// ConfirmCalls gets all the calls that were made to Confirm. +// Check the length with: +// len(mockedprompter.ConfirmCalls()) +func (mock *prompterMock) ConfirmCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockConfirm.RLock() + calls = mock.calls.Confirm + mock.lockConfirm.RUnlock() + return calls +} diff --git a/pkg/cmd/codespace/ports.go b/pkg/cmd/codespace/ports.go new file mode 100644 index 000000000..094833e30 --- /dev/null +++ b/pkg/cmd/codespace/ports.go @@ -0,0 +1,382 @@ +package codespace + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net" + "strconv" + "strings" + + "github.com/cli/cli/v2/internal/codespaces" + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/liveshare" + "github.com/cli/cli/v2/utils" + "github.com/muhammadmuzzammil1998/jsonc" + "github.com/spf13/cobra" + "golang.org/x/sync/errgroup" +) + +// newPortsCmd returns a Cobra "ports" command that displays a table of available ports, +// according to the specified flags. +func newPortsCmd(app *App) *cobra.Command { + var codespace string + var exporter cmdutil.Exporter + + portsCmd := &cobra.Command{ + Use: "ports", + Short: "List ports in a codespace", + Args: noArgsConstraint, + RunE: func(cmd *cobra.Command, args []string) error { + return app.ListPorts(cmd.Context(), codespace, exporter) + }, + } + + portsCmd.PersistentFlags().StringVarP(&codespace, "codespace", "c", "", "Name of the codespace") + cmdutil.AddJSONFlags(portsCmd, &exporter, portFields) + + portsCmd.AddCommand(newPortsForwardCmd(app)) + portsCmd.AddCommand(newPortsVisibilityCmd(app)) + + return portsCmd +} + +// ListPorts lists known ports in a codespace. +func (a *App) ListPorts(ctx context.Context, codespaceName string, exporter cmdutil.Exporter) (err error) { + codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName) + if err != nil { + // TODO(josebalius): remove special handling of this error here and it other places + if err == errNoCodespaces { + return err + } + return fmt.Errorf("error choosing codespace: %w", err) + } + + devContainerCh := getDevContainer(ctx, a.apiClient, codespace) + + session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace) + if err != nil { + return fmt.Errorf("error connecting to codespace: %w", err) + } + defer safeClose(session, &err) + + a.StartProgressIndicatorWithLabel("Fetching ports") + ports, err := session.GetSharedServers(ctx) + a.StopProgressIndicator() + if err != nil { + return fmt.Errorf("error getting ports of shared servers: %w", err) + } + + devContainerResult := <-devContainerCh + if devContainerResult.err != nil { + // Warn about failure to read the devcontainer file. Not a codespace command error. + a.errLogger.Printf("Failed to get port names: %v", devContainerResult.err.Error()) + } + + portInfos := make([]*portInfo, len(ports)) + for i, p := range ports { + portInfos[i] = &portInfo{ + Port: p, + codespace: codespace, + devContainer: devContainerResult.devContainer, + } + } + + if err := a.io.StartPager(); err != nil { + a.errLogger.Printf("error starting pager: %v", err) + } + defer a.io.StopPager() + + if exporter != nil { + return exporter.Write(a.io, portInfos) + } + + cs := a.io.ColorScheme() + tp := utils.NewTablePrinter(a.io) + + if tp.IsTTY() { + tp.AddField("LABEL", nil, nil) + tp.AddField("PORT", nil, nil) + tp.AddField("VISIBILITY", nil, nil) + tp.AddField("BROWSE URL", nil, nil) + tp.EndRow() + } + + for _, port := range portInfos { + tp.AddField(port.Label(), nil, nil) + tp.AddField(strconv.Itoa(port.SourcePort), nil, cs.Yellow) + tp.AddField(port.Privacy, nil, nil) + tp.AddField(port.BrowseURL(), nil, nil) + tp.EndRow() + } + return tp.Render() +} + +type portInfo struct { + *liveshare.Port + codespace *api.Codespace + devContainer *devContainer +} + +func (pi *portInfo) BrowseURL() string { + return fmt.Sprintf("https://%s-%d.githubpreview.dev", pi.codespace.Name, pi.Port.SourcePort) +} + +func (pi *portInfo) Label() string { + if pi.devContainer != nil { + portStr := strconv.Itoa(pi.Port.SourcePort) + if attributes, ok := pi.devContainer.PortAttributes[portStr]; ok { + return attributes.Label + } + } + return "" +} + +var portFields = []string{ + "sourcePort", + // "destinationPort", // TODO(mislav): this appears to always be blank? + "visibility", + "label", + "browseUrl", +} + +func (pi *portInfo) ExportData(fields []string) map[string]interface{} { + data := map[string]interface{}{} + + for _, f := range fields { + switch f { + case "sourcePort": + data[f] = pi.Port.SourcePort + case "destinationPort": + data[f] = pi.Port.DestinationPort + case "visibility": + data[f] = pi.Port.Privacy + case "label": + data[f] = pi.Label() + case "browseUrl": + data[f] = pi.BrowseURL() + default: + panic("unkown field: " + f) + } + } + + return data +} + +type devContainerResult struct { + devContainer *devContainer + err error +} + +type devContainer struct { + PortAttributes map[string]portAttribute `json:"portsAttributes"` +} + +type portAttribute struct { + Label string `json:"label"` +} + +func getDevContainer(ctx context.Context, apiClient apiClient, codespace *api.Codespace) <-chan devContainerResult { + ch := make(chan devContainerResult, 1) + go func() { + contents, err := apiClient.GetCodespaceRepositoryContents(ctx, codespace, ".devcontainer/devcontainer.json") + if err != nil { + ch <- devContainerResult{nil, fmt.Errorf("error getting content: %w", err)} + return + } + + if contents == nil { + ch <- devContainerResult{nil, nil} + return + } + + convertedJSON := normalizeJSON(jsonc.ToJSON(contents)) + if !jsonc.Valid(convertedJSON) { + ch <- devContainerResult{nil, errors.New("failed to convert json to standard json")} + return + } + + var container devContainer + if err := json.Unmarshal(convertedJSON, &container); err != nil { + ch <- devContainerResult{nil, fmt.Errorf("error unmarshaling: %w", err)} + return + } + + ch <- devContainerResult{&container, nil} + }() + return ch +} + +func newPortsVisibilityCmd(app *App) *cobra.Command { + return &cobra.Command{ + Use: "visibility :{public|private|org}...", + Short: "Change the visibility of the forwarded port", + Example: "gh codespace ports visibility 80:org 3000:private 8000:public", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + codespace, err := cmd.Flags().GetString("codespace") + if err != nil { + // should only happen if flag is not defined + // or if the flag is not of string type + // since it's a persistent flag that we control it should never happen + return fmt.Errorf("get codespace flag: %w", err) + } + return app.UpdatePortVisibility(cmd.Context(), codespace, args) + }, + } +} + +func (a *App) UpdatePortVisibility(ctx context.Context, codespaceName string, args []string) (err error) { + ports, err := a.parsePortVisibilities(args) + if err != nil { + return fmt.Errorf("error parsing port arguments: %w", err) + } + + codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName) + if err != nil { + if err == errNoCodespaces { + return err + } + return fmt.Errorf("error getting codespace: %w", err) + } + + session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace) + if err != nil { + return fmt.Errorf("error connecting to codespace: %w", err) + } + defer safeClose(session, &err) + + // TODO: check if port visibility can be updated in parallel instead of sequentially + for _, port := range ports { + a.StartProgressIndicatorWithLabel(fmt.Sprintf("Updating port %d visibility to: %s", port.number, port.visibility)) + err := session.UpdateSharedServerPrivacy(ctx, port.number, port.visibility) + a.StopProgressIndicator() + if err != nil { + return fmt.Errorf("error update port to public: %w", err) + } + } + + return nil +} + +type portVisibility struct { + number int + visibility string +} + +func (a *App) parsePortVisibilities(args []string) ([]portVisibility, error) { + ports := make([]portVisibility, 0, len(args)) + for _, a := range args { + fields := strings.Split(a, ":") + if len(fields) != 2 { + return nil, fmt.Errorf("invalid port visibility format for %q", a) + } + portStr, visibility := fields[0], fields[1] + portNumber, err := strconv.Atoi(portStr) + if err != nil { + return nil, fmt.Errorf("invalid port number: %w", err) + } + ports = append(ports, portVisibility{portNumber, visibility}) + } + return ports, nil +} + +// NewPortsForwardCmd returns a Cobra "ports forward" subcommand, which forwards a set of +// port pairs from the codespace to localhost. +func newPortsForwardCmd(app *App) *cobra.Command { + return &cobra.Command{ + Use: "forward :...", + Short: "Forward ports", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + codespace, err := cmd.Flags().GetString("codespace") + if err != nil { + // should only happen if flag is not defined + // or if the flag is not of string type + // since it's a persistent flag that we control it should never happen + return fmt.Errorf("get codespace flag: %w", err) + } + + return app.ForwardPorts(cmd.Context(), codespace, args) + }, + } +} + +func (a *App) ForwardPorts(ctx context.Context, codespaceName string, ports []string) (err error) { + portPairs, err := getPortPairs(ports) + if err != nil { + return fmt.Errorf("get port pairs: %w", err) + } + + codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName) + if err != nil { + if err == errNoCodespaces { + return err + } + return fmt.Errorf("error getting codespace: %w", err) + } + + session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace) + if err != nil { + return fmt.Errorf("error connecting to codespace: %w", err) + } + defer safeClose(session, &err) + + // Run forwarding of all ports concurrently, aborting all of + // them at the first failure, including cancellation of the context. + group, ctx := errgroup.WithContext(ctx) + for _, pair := range portPairs { + pair := pair + group.Go(func() error { + listen, err := net.Listen("tcp", fmt.Sprintf(":%d", pair.local)) + if err != nil { + return err + } + defer listen.Close() + + a.errLogger.Printf("Forwarding ports: remote %d <=> local %d", pair.remote, pair.local) + name := fmt.Sprintf("share-%d", pair.remote) + fwd := liveshare.NewPortForwarder(session, name, pair.remote, false) + return fwd.ForwardToListener(ctx, listen) // error always non-nil + }) + } + return group.Wait() // first error +} + +type portPair struct { + remote, local int +} + +// getPortPairs parses a list of strings of form "%d:%d" into pairs of (remote, local) numbers. +func getPortPairs(ports []string) ([]portPair, error) { + pp := make([]portPair, 0, len(ports)) + + for _, portString := range ports { + parts := strings.Split(portString, ":") + if len(parts) < 2 { + return nil, fmt.Errorf("port pair: %q is not valid", portString) + } + + remote, err := strconv.Atoi(parts[0]) + if err != nil { + return pp, fmt.Errorf("convert remote port to int: %w", err) + } + + local, err := strconv.Atoi(parts[1]) + if err != nil { + return pp, fmt.Errorf("convert local port to int: %w", err) + } + + pp = append(pp, portPair{remote, local}) + } + + return pp, nil +} + +func normalizeJSON(j []byte) []byte { + // remove trailing commas + return bytes.ReplaceAll(j, []byte("},}"), []byte("}}")) +} diff --git a/pkg/cmd/codespace/root.go b/pkg/cmd/codespace/root.go new file mode 100644 index 000000000..5b2c0d8fc --- /dev/null +++ b/pkg/cmd/codespace/root.go @@ -0,0 +1,24 @@ +package codespace + +import ( + "github.com/spf13/cobra" +) + +func NewRootCmd(app *App) *cobra.Command { + root := &cobra.Command{ + Use: "codespace", + Short: "Connect to and manage your codespaces", + } + + root.AddCommand(newCodeCmd(app)) + root.AddCommand(newCreateCmd(app)) + root.AddCommand(newDeleteCmd(app)) + root.AddCommand(newListCmd(app)) + root.AddCommand(newLogsCmd(app)) + root.AddCommand(newPortsCmd(app)) + root.AddCommand(newSSHCmd(app)) + root.AddCommand(newCpCmd(app)) + root.AddCommand(newStopCmd(app)) + + return root +} diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go new file mode 100644 index 000000000..726f2152f --- /dev/null +++ b/pkg/cmd/codespace/ssh.go @@ -0,0 +1,483 @@ +package codespace + +// This file defines the 'gh cs ssh' and 'gh cs cp' subcommands. + +import ( + "context" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "net" + "os" + "path/filepath" + "strings" + "sync" + "text/template" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/codespaces" + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/liveshare" + "github.com/spf13/cobra" +) + +type sshOptions struct { + codespace string + profile string + serverPort int + debug bool + debugFile string + stdio bool + config bool + scpArgs []string // scp arguments, for 'cs cp' (nil for 'cs ssh') +} + +func newSSHCmd(app *App) *cobra.Command { + var opts sshOptions + + sshCmd := &cobra.Command{ + Use: "ssh [...] [-- ...] []", + Short: "SSH into a codespace", + Long: heredoc.Doc(` + The 'ssh' command is used to SSH into a codespace. In its simplest form, you can + run 'gh cs ssh', select a codespace interactively, and connect. + + The 'ssh' command also supports deeper integration with OpenSSH using a + '--config' option that generates per-codespace ssh configuration in OpenSSH + format. Including this configuration in your ~/.ssh/config improves the user + experience of tools that integrate with OpenSSH, such as bash/zsh completion of + ssh hostnames, remote path completion for scp/rsync/sshfs, git ssh remotes, and + so on. + + Once that is set up (see the second example below), you can ssh to codespaces as + if they were ordinary remote hosts (using 'ssh', not 'gh cs ssh'). + `), + Example: heredoc.Doc(` + $ gh codespace ssh + + $ gh codespace ssh --config > ~/.ssh/codespaces + $ echo 'include ~/.ssh/codespaces' >> ~/.ssh/config' + `), + PreRunE: func(c *cobra.Command, args []string) error { + if opts.stdio { + if opts.codespace == "" { + return errors.New("`--stdio` requires explicit `--codespace`") + } + if opts.config { + return errors.New("cannot use `--stdio` with `--config`") + } + if opts.serverPort != 0 { + return errors.New("cannot use `--stdio` with `--server-port`") + } + if opts.profile != "" { + return errors.New("cannot use `--stdio` with `--profile`") + } + } + if opts.config { + if opts.profile != "" { + return errors.New("cannot use `--config` with `--profile`") + } + if opts.serverPort != 0 { + return errors.New("cannot use `--config` with `--server-port`") + } + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.config { + return app.printOpenSSHConfig(cmd.Context(), opts) + } else { + return app.SSH(cmd.Context(), args, opts) + } + }, + DisableFlagsInUseLine: true, + } + + sshCmd.Flags().StringVarP(&opts.profile, "profile", "", "", "Name of the SSH profile to use") + sshCmd.Flags().IntVarP(&opts.serverPort, "server-port", "", 0, "SSH server port number (0 => pick unused)") + sshCmd.Flags().StringVarP(&opts.codespace, "codespace", "c", "", "Name of the codespace") + sshCmd.Flags().BoolVarP(&opts.debug, "debug", "d", false, "Log debug data to a file") + sshCmd.Flags().StringVarP(&opts.debugFile, "debug-file", "", "", "Path of the file log to") + sshCmd.Flags().BoolVarP(&opts.config, "config", "", false, "Write OpenSSH configuration to stdout") + sshCmd.Flags().BoolVar(&opts.stdio, "stdio", false, "Proxy sshd connection to stdio") + if err := sshCmd.Flags().MarkHidden("stdio"); err != nil { + fmt.Fprintf(os.Stderr, "%v\n", err) + } + + return sshCmd +} + +// SSH opens an ssh session or runs an ssh command in a codespace. +func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err error) { + // Ensure all child tasks (e.g. port forwarding) terminate before return. + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // While connecting, ensure in the background that the user has keys installed. + // That lets us report a more useful error message if they don't. + authkeys := make(chan error, 1) + go func() { + authkeys <- checkAuthorizedKeys(ctx, a.apiClient) + }() + + codespace, err := getOrChooseCodespace(ctx, a.apiClient, opts.codespace) + if err != nil { + return fmt.Errorf("get or choose codespace: %w", err) + } + + liveshareLogger := noopLogger() + if opts.debug { + debugLogger, err := newFileLogger(opts.debugFile) + if err != nil { + return fmt.Errorf("error creating debug logger: %w", err) + } + defer safeClose(debugLogger, &err) + + liveshareLogger = debugLogger.Logger + a.errLogger.Printf("Debug file located at: %s", debugLogger.Name()) + } + + session, err := codespaces.ConnectToLiveshare(ctx, a, liveshareLogger, a.apiClient, codespace) + if err != nil { + if authErr := <-authkeys; authErr != nil { + return authErr + } + return fmt.Errorf("error connecting to codespace: %w", err) + } + defer safeClose(session, &err) + + a.StartProgressIndicatorWithLabel("Fetching SSH Details") + remoteSSHServerPort, sshUser, err := session.StartSSHServer(ctx) + a.StopProgressIndicator() + if err != nil { + return fmt.Errorf("error getting ssh server details: %w", err) + } + + if opts.stdio { + fwd := liveshare.NewPortForwarder(session, "sshd", remoteSSHServerPort, true) + stdio := newReadWriteCloser(os.Stdin, os.Stdout) + err := fwd.Forward(ctx, stdio) // always non-nil + return fmt.Errorf("tunnel closed: %w", err) + } + + localSSHServerPort := opts.serverPort + usingCustomPort := localSSHServerPort != 0 // suppress log of command line in Shell + + // Ensure local port is listening before client (Shell) connects. + // Unless the user specifies a server port, localSSHServerPort is 0 + // and thus the client will pick a random port. + listen, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localSSHServerPort)) + if err != nil { + return err + } + defer listen.Close() + localSSHServerPort = listen.Addr().(*net.TCPAddr).Port + + connectDestination := opts.profile + if connectDestination == "" { + connectDestination = fmt.Sprintf("%s@localhost", sshUser) + } + + tunnelClosed := make(chan error, 1) + go func() { + fwd := liveshare.NewPortForwarder(session, "sshd", remoteSSHServerPort, true) + tunnelClosed <- fwd.ForwardToListener(ctx, listen) // always non-nil + }() + + shellClosed := make(chan error, 1) + go func() { + var err error + if opts.scpArgs != nil { + err = codespaces.Copy(ctx, opts.scpArgs, localSSHServerPort, connectDestination) + } else { + err = codespaces.Shell(ctx, a.errLogger, sshArgs, localSSHServerPort, connectDestination, usingCustomPort) + } + shellClosed <- err + }() + + select { + case err := <-tunnelClosed: + return fmt.Errorf("tunnel closed: %w", err) + case err := <-shellClosed: + if err != nil { + return fmt.Errorf("shell closed: %w", err) + } + return nil // success + } +} + +func (a *App) printOpenSSHConfig(ctx context.Context, opts sshOptions) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var err error + var csList []*api.Codespace + if opts.codespace == "" { + a.StartProgressIndicatorWithLabel("Fetching codespaces") + csList, err = a.apiClient.ListCodespaces(ctx, -1) + a.StopProgressIndicator() + } else { + var codespace *api.Codespace + codespace, err = getOrChooseCodespace(ctx, a.apiClient, opts.codespace) + csList = []*api.Codespace{codespace} + } + if err != nil { + return fmt.Errorf("error getting codespace info: %w", err) + } + + type sshResult struct { + codespace *api.Codespace + user string // on success, the remote ssh username; else nil + err error + } + + sshUsers := make(chan sshResult, len(csList)) + var wg sync.WaitGroup + var status error + for _, cs := range csList { + if cs.State != "Available" && opts.codespace == "" { + fmt.Fprintf(os.Stderr, "skipping unavailable codespace %s: %s\n", cs.Name, cs.State) + status = cmdutil.SilentError + continue + } + + cs := cs + wg.Add(1) + go func() { + result := sshResult{} + defer wg.Done() + + session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, cs) + if err != nil { + result.err = fmt.Errorf("error connecting to codespace: %w", err) + } else { + defer session.Close() + + _, result.user, err = session.StartSSHServer(ctx) + if err != nil { + result.err = fmt.Errorf("error getting ssh server details: %w", err) + } else { + result.codespace = cs + } + } + + sshUsers <- result + }() + } + + go func() { + wg.Wait() + close(sshUsers) + }() + + // While the above fetches are running, ensure that the user has keys installed. + // That lets us report a more useful error message if they don't. + if err = checkAuthorizedKeys(ctx, a.apiClient); err != nil { + return err + } + + t, err := template.New("ssh_config").Parse(heredoc.Doc(` + Host cs.{{.Name}}.{{.EscapedRef}} + User {{.SSHUser}} + ProxyCommand {{.GHExec}} cs ssh -c {{.Name}} --stdio + UserKnownHostsFile=/dev/null + StrictHostKeyChecking no + LogLevel quiet + ControlMaster auto + + `)) + if err != nil { + return fmt.Errorf("error formatting template: %w", err) + } + + ghExec := a.executable.Executable() + for result := range sshUsers { + if result.err != nil { + fmt.Fprintf(os.Stderr, "%v\n", result.err) + status = cmdutil.SilentError + continue + } + + // codespaceSSHConfig contains values needed to write an OpenSSH host + // configuration for a single codespace. For example: + // + // Host {{Name}}.{{EscapedRef} + // User {{SSHUser} + // ProxyCommand {{GHExec}} cs ssh -c {{Name}} --stdio + // + // EscapedRef is included in the name to help distinguish between codespaces + // when tab-completing ssh hostnames. '/' characters in EscapedRef are + // flattened to '-' to prevent problems with tab completion or when the + // hostname appears in ControlMaster socket paths. + type codespaceSSHConfig struct { + Name string // the codespace name, passed to `ssh -c` + EscapedRef string // the currently checked-out branch + SSHUser string // the remote ssh username + GHExec string // path used for invoking the current `gh` binary + } + + conf := codespaceSSHConfig{ + Name: result.codespace.Name, + EscapedRef: strings.ReplaceAll(result.codespace.GitStatus.Ref, "/", "-"), + SSHUser: result.user, + GHExec: ghExec, + } + if err := t.Execute(a.io.Out, conf); err != nil { + return err + } + } + + return status +} + +type cpOptions struct { + sshOptions + recursive bool // -r + expand bool // -e +} + +func newCpCmd(app *App) *cobra.Command { + var opts cpOptions + + cpCmd := &cobra.Command{ + Use: "cp [-e] [-r] ... ", + Short: "Copy files between local and remote file systems", + Long: heredoc.Docf(` + The cp command copies files between the local and remote file systems. + + As with the UNIX %[1]scp%[1]s command, the first argument specifies the source and the last + specifies the destination; additional sources may be specified after the first, + if the destination is a directory. + + The %[1]s--recursive%[1]s flag is required if any source is a directory. + + A "remote:" prefix on any file name argument indicates that it refers to + the file system of the remote (Codespace) machine. It is resolved relative + to the home directory of the remote user. + + By default, remote file names are interpreted literally. With the %[1]s--expand%[1]s flag, + each such argument is treated in the manner of %[1]sscp%[1]s, as a Bash expression to + be evaluated on the remote machine, subject to expansion of tildes, braces, globs, + environment variables, and backticks. For security, do not use this flag with arguments + provided by untrusted users; see for discussion. + `, "`"), + Example: heredoc.Doc(` + $ gh codespace cp -e README.md 'remote:/workspaces/$RepositoryName/' + $ gh codespace cp -e 'remote:~/*.go' ./gofiles/ + $ gh codespace cp -e 'remote:/workspaces/myproj/go.{mod,sum}' ./gofiles/ + `), + RunE: func(cmd *cobra.Command, args []string) error { + return app.Copy(cmd.Context(), args, opts) + }, + DisableFlagsInUseLine: true, + } + + // We don't expose all sshOptions. + cpCmd.Flags().BoolVarP(&opts.recursive, "recursive", "r", false, "Recursively copy directories") + cpCmd.Flags().BoolVarP(&opts.expand, "expand", "e", false, "Expand remote file names on remote shell") + cpCmd.Flags().StringVarP(&opts.codespace, "codespace", "c", "", "Name of the codespace") + return cpCmd +} + +// Copy copies files between the local and remote file systems. +// The mechanics are similar to 'ssh' but using 'scp'. +func (a *App) Copy(ctx context.Context, args []string, opts cpOptions) error { + if len(args) < 2 { + return fmt.Errorf("cp requires source and destination arguments") + } + if opts.recursive { + opts.scpArgs = append(opts.scpArgs, "-r") + } + opts.scpArgs = append(opts.scpArgs, "--") + hasRemote := false + for _, arg := range args { + if rest := strings.TrimPrefix(arg, "remote:"); rest != arg { + hasRemote = true + // scp treats each filename argument as a shell expression, + // subjecting it to expansion of environment variables, braces, + // tilde, backticks, globs and so on. Because these present a + // security risk (see https://lwn.net/Articles/835962/), we + // disable them by shell-escaping the argument unless the user + // provided the -e flag. + if !opts.expand { + arg = `remote:'` + strings.Replace(rest, `'`, `'\''`, -1) + `'` + } + + } else if !filepath.IsAbs(arg) { + // scp treats a colon in the first path segment as a host identifier. + // Escape it by prepending "./". + // TODO(adonovan): test on Windows, including with a c:\\foo path. + const sep = string(os.PathSeparator) + first := strings.Split(filepath.ToSlash(arg), sep)[0] + if strings.Contains(first, ":") { + arg = "." + sep + arg + } + } + opts.scpArgs = append(opts.scpArgs, arg) + } + if !hasRemote { + return cmdutil.FlagErrorf("at least one argument must have a 'remote:' prefix") + } + return a.SSH(ctx, nil, opts.sshOptions) +} + +// fileLogger is a wrapper around an log.Logger configured to write +// to a file. It exports two additional methods to get the log file name +// and close the file handle when the operation is finished. +type fileLogger struct { + *log.Logger + + f *os.File +} + +// newFileLogger creates a new fileLogger. It returns an error if the file +// cannot be created. The file is created on the specified path, if the path +// is empty it is created in the temporary directory. +func newFileLogger(file string) (fl *fileLogger, err error) { + var f *os.File + if file == "" { + f, err = ioutil.TempFile("", "") + if err != nil { + return nil, fmt.Errorf("failed to create tmp file: %w", err) + } + } else { + f, err = os.Create(file) + if err != nil { + return nil, err + } + } + + return &fileLogger{ + Logger: log.New(f, "", log.LstdFlags), + f: f, + }, nil +} + +func (fl *fileLogger) Name() string { + return fl.f.Name() +} + +func (fl *fileLogger) Close() error { + return fl.f.Close() +} + +type combinedReadWriteCloser struct { + io.ReadCloser + io.WriteCloser +} + +func newReadWriteCloser(reader io.ReadCloser, writer io.WriteCloser) io.ReadWriteCloser { + return &combinedReadWriteCloser{reader, writer} +} + +func (crwc *combinedReadWriteCloser) Close() error { + werr := crwc.WriteCloser.Close() + rerr := crwc.ReadCloser.Close() + if werr != nil { + return werr + } + return rerr +} diff --git a/pkg/cmd/codespace/stop.go b/pkg/cmd/codespace/stop.go new file mode 100644 index 000000000..6b2268fad --- /dev/null +++ b/pkg/cmd/codespace/stop.go @@ -0,0 +1,73 @@ +package codespace + +import ( + "context" + "errors" + "fmt" + + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/spf13/cobra" +) + +func newStopCmd(app *App) *cobra.Command { + var codespace string + + stopCmd := &cobra.Command{ + Use: "stop", + Short: "Stop a running codespace", + Args: noArgsConstraint, + RunE: func(cmd *cobra.Command, args []string) error { + return app.StopCodespace(cmd.Context(), codespace) + }, + } + stopCmd.Flags().StringVarP(&codespace, "codespace", "c", "", "Name of the codespace") + + return stopCmd +} + +func (a *App) StopCodespace(ctx context.Context, codespaceName string) error { + if codespaceName == "" { + a.StartProgressIndicatorWithLabel("Fetching codespaces") + codespaces, err := a.apiClient.ListCodespaces(ctx, -1) + a.StopProgressIndicator() + if err != nil { + return fmt.Errorf("failed to list codespaces: %w", err) + } + + var runningCodespaces []*api.Codespace + for _, c := range codespaces { + cs := codespace{c} + if cs.running() { + runningCodespaces = append(runningCodespaces, c) + } + } + if len(runningCodespaces) == 0 { + return errors.New("no running codespaces") + } + + codespace, err := chooseCodespaceFromList(ctx, runningCodespaces) + if err != nil { + return fmt.Errorf("failed to choose codespace: %w", err) + } + codespaceName = codespace.Name + } else { + a.StartProgressIndicatorWithLabel("Fetching codespace") + c, err := a.apiClient.GetCodespace(ctx, codespaceName, false) + a.StopProgressIndicator() + if err != nil { + return fmt.Errorf("failed to get codespace: %q: %w", codespaceName, err) + } + cs := codespace{c} + if !cs.running() { + return fmt.Errorf("codespace %q is not running", codespaceName) + } + } + + a.StartProgressIndicatorWithLabel("Stopping codespace") + defer a.StopProgressIndicator() + if err := a.apiClient.StopCodespace(ctx, codespaceName); err != nil { + return fmt.Errorf("failed to stop codespace: %w", err) + } + + return nil +} diff --git a/pkg/cmd/completion/completion.go b/pkg/cmd/completion/completion.go index e711d6db6..496f06784 100644 --- a/pkg/cmd/completion/completion.go +++ b/pkg/cmd/completion/completion.go @@ -1,7 +1,6 @@ package completion import ( - "errors" "fmt" "github.com/MakeNowJust/heredoc" @@ -68,7 +67,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { if shellType == "" { if io.IsStdoutTTY() { - return &cmdutil.FlagError{Err: errors.New("error: the value for `--shell` is required")} + return cmdutil.FlagErrorf("error: the value for `--shell` is required") } shellType = "bash" } diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go index c011ddcf0..2168516d3 100644 --- a/pkg/cmd/config/config.go +++ b/pkg/cmd/config/config.go @@ -6,6 +6,7 @@ import ( "github.com/cli/cli/v2/internal/config" cmdGet "github.com/cli/cli/v2/pkg/cmd/config/get" + cmdList "github.com/cli/cli/v2/pkg/cmd/config/list" cmdSet "github.com/cli/cli/v2/pkg/cmd/config/set" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -33,6 +34,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdGet.NewCmdConfigGet(f, nil)) cmd.AddCommand(cmdSet.NewCmdConfigSet(f, nil)) + cmd.AddCommand(cmdList.NewCmdConfigList(f, nil)) return cmd } diff --git a/pkg/cmd/config/get/get.go b/pkg/cmd/config/get/get.go index 3a5634458..94694adb2 100644 --- a/pkg/cmd/config/get/get.go +++ b/pkg/cmd/config/get/get.go @@ -53,7 +53,7 @@ func NewCmdConfigGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Co } func getRun(opts *GetOptions) error { - val, err := opts.Config.Get(opts.Hostname, opts.Key) + val, err := opts.Config.GetOrDefault(opts.Hostname, opts.Key) if err != nil { return err } diff --git a/pkg/cmd/config/get/get_test.go b/pkg/cmd/config/get/get_test.go index 7c5efa9be..46f187394 100644 --- a/pkg/cmd/config/get/get_test.go +++ b/pkg/cmd/config/get/get_test.go @@ -115,6 +115,8 @@ func Test_getRun(t *testing.T) { assert.NoError(t, err) assert.Equal(t, tt.stdout, stdout.String()) assert.Equal(t, tt.stderr, stderr.String()) + _, err = tt.input.Config.GetOrDefault("", "_written") + assert.Error(t, err) _, err = tt.input.Config.Get("", "_written") assert.Error(t, err) }) diff --git a/pkg/cmd/config/list/list.go b/pkg/cmd/config/list/list.go new file mode 100644 index 000000000..c2ad0397b --- /dev/null +++ b/pkg/cmd/config/list/list.go @@ -0,0 +1,70 @@ +package list + +import ( + "fmt" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/spf13/cobra" +) + +type ListOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + + Hostname string +} + +func NewCmdConfigList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + Config: f.Config, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "Print a list of configuration keys and values", + Args: cobra.ExactArgs(0), + RunE: func(cmd *cobra.Command, args []string) error { + if runF != nil { + return runF(opts) + } + + return listRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Hostname, "host", "h", "", "Get per-host configuration") + + return cmd +} + +func listRun(opts *ListOptions) error { + cfg, err := opts.Config() + if err != nil { + return err + } + + var host string + if opts.Hostname != "" { + host = opts.Hostname + } else { + host, err = cfg.DefaultHost() + if err != nil { + return err + } + } + + configOptions := config.ConfigOptions() + + for _, key := range configOptions { + val, err := cfg.GetOrDefault(host, key.Key) + if err != nil { + return err + } + fmt.Fprintf(opts.IO.Out, "%s=%s\n", key.Key, val) + } + + return nil +} diff --git a/pkg/cmd/config/list/list_test.go b/pkg/cmd/config/list/list_test.go new file mode 100644 index 000000000..14f9aba4b --- /dev/null +++ b/pkg/cmd/config/list/list_test.go @@ -0,0 +1,113 @@ +package list + +import ( + "bytes" + "testing" + + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdConfigList(t *testing.T) { + tests := []struct { + name string + input string + output ListOptions + wantsErr bool + }{ + { + name: "no arguments", + input: "", + output: ListOptions{}, + wantsErr: false, + }, + { + name: "list with host", + input: "--host HOST.com", + output: ListOptions{Hostname: "HOST.com"}, + wantsErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := &cmdutil.Factory{ + Config: func() (config.Config, error) { + return config.ConfigStub{}, nil + }, + } + + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + + var gotOpts *ListOptions + cmd := NewCmdConfigList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + cmd.Flags().BoolP("help", "x", false, "") + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.output.Hostname, gotOpts.Hostname) + }) + } +} + +func Test_listRun(t *testing.T) { + tests := []struct { + name string + input *ListOptions + config config.ConfigStub + stdout string + wantErr bool + }{ + { + name: "list", + config: config.ConfigStub{ + "HOST:git_protocol": "ssh", + "HOST:editor": "/usr/bin/vim", + "HOST:prompt": "disabled", + "HOST:pager": "less", + "HOST:http_unix_socket": "", + "HOST:browser": "brave", + }, + input: &ListOptions{Hostname: "HOST"}, // ConfigStub gives empty DefaultHost + stdout: `git_protocol=ssh +editor=/usr/bin/vim +prompt=disabled +pager=less +http_unix_socket= +browser=brave +`, + }, + } + + for _, tt := range tests { + io, _, stdout, _ := iostreams.Test() + tt.input.IO = io + tt.input.Config = func() (config.Config, error) { + return tt.config, nil + } + + t.Run(tt.name, func(t *testing.T) { + err := listRun(tt.input) + assert.NoError(t, err) + assert.Equal(t, tt.stdout, stdout.String()) + //assert.Equal(t, tt.stderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/config/set/set_test.go b/pkg/cmd/config/set/set_test.go index cdd2e7c94..2beb20edc 100644 --- a/pkg/cmd/config/set/set_test.go +++ b/pkg/cmd/config/set/set_test.go @@ -145,11 +145,11 @@ func Test_setRun(t *testing.T) { assert.Equal(t, tt.stdout, stdout.String()) assert.Equal(t, tt.stderr, stderr.String()) - val, err := tt.input.Config.Get(tt.input.Hostname, tt.input.Key) + val, err := tt.input.Config.GetOrDefault(tt.input.Hostname, tt.input.Key) assert.NoError(t, err) assert.Equal(t, tt.expectedValue, val) - val, err = tt.input.Config.Get("", "_written") + val, err = tt.input.Config.GetOrDefault("", "_written") assert.NoError(t, err) assert.Equal(t, "true", val) }) diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 0ea822e8c..de39d0287 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -6,11 +6,13 @@ import ( "os" "strings" + "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/prompt" "github.com/cli/cli/v2/utils" "github.com/spf13/cobra" ) @@ -30,6 +32,8 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { will be forwarded to the %[1]sgh-%[1]s executable of the extension. An extension cannot override any of the core gh commands. + + See the list of available extensions at `, "`"), Aliases: []string{"extensions"}, } @@ -67,9 +71,25 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { }, }, &cobra.Command{ - Use: "install ", + Use: "install ", Short: "Install a gh extension from a repository", - Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"), + Long: heredoc.Doc(` + Install a GitHub repository locally as a GitHub CLI extension. + + The repository argument can be specified in "owner/repo" format as well as a full URL. + The URL format is useful when the repository is not hosted on github.com. + + To install an extension in development from the current directory, use "." as the + value of the repository argument. + + See the list of available extensions at + `), + Example: heredoc.Doc(` + $ gh extension install owner/gh-extension + $ gh extension install https://git.example.com/owner/gh-extension + $ gh extension install . + `), + Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"), RunE: func(cmd *cobra.Command, args []string) error { if args[0] == "." { wd, err := os.Getwd() @@ -83,16 +103,20 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { if err != nil { return err } + if err := checkValidExtension(cmd.Root(), m, repo.RepoName()); err != nil { return err } - cfg, err := f.Config() - if err != nil { + if err := m.Install(repo); err != nil { return err } - protocol, _ := cfg.Get(repo.RepoHost(), "git_protocol") - return m.Install(ghrepo.FormatRemoteURL(repo, protocol), io.Out, io.ErrOut) + + if io.IsStdoutTTY() { + cs := io.ColorScheme() + fmt.Fprintf(io.Out, "%s Installed extension %s\n", cs.SuccessIcon(), args[0]) + } + return nil }, }, func() *cobra.Command { @@ -103,13 +127,13 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { Short: "Upgrade installed extensions", Args: func(cmd *cobra.Command, args []string) error { if len(args) == 0 && !flagAll { - return &cmdutil.FlagError{Err: errors.New("must specify an extension to upgrade")} + return cmdutil.FlagErrorf("specify an extension to upgrade or `--all`") } if len(args) > 0 && flagAll { - return &cmdutil.FlagError{Err: errors.New("cannot use `--all` with extension name")} + return cmdutil.FlagErrorf("cannot use `--all` with extension name") } if len(args) > 1 { - return &cmdutil.FlagError{Err: errors.New("too many arguments")} + return cmdutil.FlagErrorf("too many arguments") } return nil }, @@ -118,7 +142,26 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { if len(args) > 0 { name = normalizeExtensionSelector(args[0]) } - return m.Upgrade(name, flagForce, io.Out, io.ErrOut) + cs := io.ColorScheme() + err := m.Upgrade(name, flagForce) + if err != nil && !errors.Is(err, upToDateError) { + if name != "" { + fmt.Fprintf(io.ErrOut, "%s Failed upgrading extension %s: %s\n", cs.FailureIcon(), name, err) + } else { + fmt.Fprintf(io.ErrOut, "%s Failed upgrading extensions\n", cs.FailureIcon()) + } + return cmdutil.SilentError + } + if io.IsStdoutTTY() { + if errors.Is(err, upToDateError) { + fmt.Fprintf(io.Out, "%s Extension already up to date\n", cs.SuccessIcon()) + } else if name != "" { + fmt.Fprintf(io.Out, "%s Successfully upgraded extension %s\n", cs.SuccessIcon(), name) + } else { + fmt.Fprintf(io.Out, "%s Successfully upgraded extensions\n", cs.SuccessIcon()) + } + } + return nil }, } cmd.Flags().BoolVar(&flagAll, "all", false, "Upgrade all extensions") @@ -141,41 +184,127 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { return nil }, }, - &cobra.Command{ - Use: "create ", - Short: "Create a new extension", - Args: cmdutil.ExactArgs(1, "must specify a name for the extension"), - RunE: func(cmd *cobra.Command, args []string) error { - extName := args[0] - if !strings.HasPrefix(extName, "gh-") { - extName = "gh-" + extName + func() *cobra.Command { + promptCreate := func() (string, extensions.ExtTemplateType, error) { + var extName string + var extTmplType int + err := prompt.SurveyAskOne(&survey.Input{ + Message: "Extension name:", + }, &extName) + if err != nil { + return extName, -1, err } - if err := m.Create(extName); err != nil { - return err - } - if !io.IsStdoutTTY() { - return nil - } - link := "https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions" - cs := io.ColorScheme() - out := heredoc.Docf(` + err = prompt.SurveyAskOne(&survey.Select{ + Message: "What kind of extension?", + Options: []string{ + "Script (Bash, Ruby, Python, etc)", + "Go", + "Other Precompiled (C++, Rust, etc)", + }, + }, &extTmplType) + return extName, extensions.ExtTemplateType(extTmplType), err + } + var flagType string + cmd := &cobra.Command{ + Use: "create []", + Short: "Create a new extension", + Example: heredoc.Doc(` + # Use interactively + gh extension create + + # Create a script-based extension + gh extension create foobar + + # Create a Go extension + gh extension create --precompiled=go foobar + + # Create a non-Go precompiled extension + gh extension create --precompiled=other foobar + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("precompiled") { + if flagType != "go" && flagType != "other" { + return cmdutil.FlagErrorf("value for --precompiled must be 'go' or 'other'. Got '%s'", flagType) + } + } + var extName string + var err error + tmplType := extensions.GitTemplateType + if len(args) == 0 { + if io.IsStdoutTTY() { + extName, tmplType, err = promptCreate() + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + } + } else { + extName = args[0] + if flagType == "go" { + tmplType = extensions.GoBinTemplateType + } else if flagType == "other" { + tmplType = extensions.OtherBinTemplateType + } + } + + var fullName string + + if strings.HasPrefix(extName, "gh-") { + fullName = extName + extName = extName[3:] + } else { + fullName = "gh-" + extName + } + if err := m.Create(fullName, tmplType); err != nil { + return err + } + if !io.IsStdoutTTY() { + return nil + } + + var goBinChecks string + + steps := fmt.Sprintf( + "- run 'cd %[1]s; gh extension install .; gh %[2]s' to see your new extension in action", + fullName, extName) + + cs := io.ColorScheme() + if tmplType == extensions.GoBinTemplateType { + goBinChecks = heredoc.Docf(` + %[1]s Downloaded Go dependencies + %[1]s Built %[2]s binary + `, cs.SuccessIcon(), fullName) + steps = heredoc.Docf(` + - run 'cd %[1]s; gh extension install .; gh %[2]s' to see your new extension in action + - use 'go build && gh %[2]s' to see changes in your code as you develop`, fullName, extName) + } else if tmplType == extensions.OtherBinTemplateType { + steps = heredoc.Docf(` + - run 'cd %[1]s; gh extension install .' to install your extension locally + - fill in script/build.sh with your compilation script for automated builds + - compile a %[1]s binary locally and run 'gh %[2]s' to see changes`, fullName, extName) + } + link := "https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions" + out := heredoc.Docf(` %[1]s Created directory %[2]s %[1]s Initialized git repository %[1]s Set up extension scaffolding + %[6]s + %[2]s is ready for development! - %[2]s is ready for development - - Install locally with: cd %[2]s && gh extension install . - - Publish to GitHub with: gh repo create %[2]s + %[4]s + %[5]s + - commit and use 'gh repo create' to share your extension with others For more information on writing extensions: %[3]s - `, cs.SuccessIcon(), extName, link) - fmt.Fprint(io.Out, out) - return nil - }, - }, + `, cs.SuccessIcon(), fullName, link, cs.Bold("Next Steps"), steps, goBinChecks) + fmt.Fprint(io.Out, out) + return nil + }, + } + cmd.Flags().StringVar(&flagType, "precompiled", "", "Create a precompiled extension. Possible values: go, other") + return cmd + }(), ) return &extCmd diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index 9dc46b3ba..8f896eab0 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -1,17 +1,21 @@ package extension import ( - "io" + "errors" "io/ioutil" + "net/http" "os" "strings" "testing" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/httpmock" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/prompt" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" ) @@ -26,6 +30,7 @@ func TestNewCmdExtension(t *testing.T) { name string args []string managerStubs func(em *extensions.ExtensionManagerMock) func(*testing.T) + askStubs func(as *prompt.AskStubber) isTTY bool wantErr bool errMsg string @@ -39,13 +44,13 @@ func TestNewCmdExtension(t *testing.T) { em.ListFunc = func(bool) []extensions.Extension { return []extensions.Extension{} } - em.InstallFunc = func(s string, out, errOut io.Writer) error { + em.InstallFunc = func(_ ghrepo.Interface) error { return nil } return func(t *testing.T) { installCalls := em.InstallCalls() assert.Equal(t, 1, len(installCalls)) - assert.Equal(t, "https://github.com/owner/gh-some-ext.git", installCalls[0].URL) + assert.Equal(t, "gh-some-ext", installCalls[0].InterfaceMoqParam.RepoName()) listCalls := em.ListCalls() assert.Equal(t, 1, len(listCalls)) } @@ -82,16 +87,16 @@ func TestNewCmdExtension(t *testing.T) { }, }, { - name: "upgrade error", + name: "upgrade argument error", args: []string{"upgrade"}, wantErr: true, - errMsg: "must specify an extension to upgrade", + errMsg: "specify an extension to upgrade or `--all`", }, { name: "upgrade an extension", args: []string{"upgrade", "hello"}, managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { - em.UpgradeFunc = func(name string, force bool, out, errOut io.Writer) error { + em.UpgradeFunc = func(name string, force bool) error { return nil } return func(t *testing.T) { @@ -100,12 +105,65 @@ func TestNewCmdExtension(t *testing.T) { assert.Equal(t, "hello", calls[0].Name) } }, + isTTY: true, + wantStdout: "✓ Successfully upgraded extension hello\n", + }, + { + name: "upgrade an extension notty", + args: []string{"upgrade", "hello"}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.UpgradeFunc = func(name string, force bool) error { + return nil + } + return func(t *testing.T) { + calls := em.UpgradeCalls() + assert.Equal(t, 1, len(calls)) + assert.Equal(t, "hello", calls[0].Name) + } + }, + isTTY: false, + }, + { + name: "upgrade an up-to-date extension", + args: []string{"upgrade", "hello"}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.UpgradeFunc = func(name string, force bool) error { + return upToDateError + } + return func(t *testing.T) { + calls := em.UpgradeCalls() + assert.Equal(t, 1, len(calls)) + assert.Equal(t, "hello", calls[0].Name) + } + }, + isTTY: true, + wantStdout: "✓ Extension already up to date\n", + wantStderr: "", + }, + { + name: "upgrade extension error", + args: []string{"upgrade", "hello"}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.UpgradeFunc = func(name string, force bool) error { + return errors.New("oh no") + } + return func(t *testing.T) { + calls := em.UpgradeCalls() + assert.Equal(t, 1, len(calls)) + assert.Equal(t, "hello", calls[0].Name) + } + }, + isTTY: false, + wantErr: true, + errMsg: "SilentError", + wantStdout: "", + wantStderr: "X Failed upgrading extension hello: oh no\n", }, { name: "upgrade an extension gh-prefix", args: []string{"upgrade", "gh-hello"}, managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { - em.UpgradeFunc = func(name string, force bool, out, errOut io.Writer) error { + em.UpgradeFunc = func(name string, force bool) error { return nil } return func(t *testing.T) { @@ -114,12 +172,14 @@ func TestNewCmdExtension(t *testing.T) { assert.Equal(t, "hello", calls[0].Name) } }, + isTTY: true, + wantStdout: "✓ Successfully upgraded extension hello\n", }, { name: "upgrade an extension full name", args: []string{"upgrade", "monalisa/gh-hello"}, managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { - em.UpgradeFunc = func(name string, force bool, out, errOut io.Writer) error { + em.UpgradeFunc = func(name string, force bool) error { return nil } return func(t *testing.T) { @@ -128,12 +188,14 @@ func TestNewCmdExtension(t *testing.T) { assert.Equal(t, "hello", calls[0].Name) } }, + isTTY: true, + wantStdout: "✓ Successfully upgraded extension hello\n", }, { name: "upgrade all", args: []string{"upgrade", "--all"}, managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { - em.UpgradeFunc = func(name string, force bool, out, errOut io.Writer) error { + em.UpgradeFunc = func(name string, force bool) error { return nil } return func(t *testing.T) { @@ -142,6 +204,23 @@ func TestNewCmdExtension(t *testing.T) { assert.Equal(t, "", calls[0].Name) } }, + isTTY: true, + wantStdout: "✓ Successfully upgraded extensions\n", + }, + { + name: "upgrade all notty", + args: []string{"upgrade", "--all"}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.UpgradeFunc = func(name string, force bool) error { + return nil + } + return func(t *testing.T) { + calls := em.UpgradeCalls() + assert.Equal(t, 1, len(calls)) + assert.Equal(t, "", calls[0].Name) + } + }, + isTTY: false, }, { name: "remove extension tty", @@ -212,8 +291,8 @@ func TestNewCmdExtension(t *testing.T) { args: []string{"list"}, managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { em.ListFunc = func(bool) []extensions.Extension { - ex1 := &Extension{path: "cli/gh-test", url: "https://github.com/cli/gh-test", updateAvailable: false} - ex2 := &Extension{path: "cli/gh-test2", url: "https://github.com/cli/gh-test2", updateAvailable: true} + ex1 := &Extension{path: "cli/gh-test", url: "https://github.com/cli/gh-test", currentVersion: "1", latestVersion: "1"} + ex2 := &Extension{path: "cli/gh-test2", url: "https://github.com/cli/gh-test2", currentVersion: "1", latestVersion: "2"} return []extensions.Extension{ex1, ex2} } return func(t *testing.T) { @@ -223,10 +302,77 @@ func TestNewCmdExtension(t *testing.T) { wantStdout: "gh test\tcli/gh-test\t\ngh test2\tcli/gh-test2\tUpgrade available\n", }, { - name: "create extension tty", - args: []string{"create", "test"}, + name: "create extension interactive", + args: []string{"create"}, managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { - em.CreateFunc = func(name string) error { + em.CreateFunc = func(name string, tmplType extensions.ExtTemplateType) error { + return nil + } + return func(t *testing.T) { + calls := em.CreateCalls() + assert.Equal(t, 1, len(calls)) + assert.Equal(t, "gh-test", calls[0].Name) + } + }, + isTTY: true, + askStubs: func(as *prompt.AskStubber) { + as.StubPrompt("Extension name:").AnswerWith("test") + as.StubPrompt("What kind of extension?"). + AssertOptions([]string{"Script (Bash, Ruby, Python, etc)", "Go", "Other Precompiled (C++, Rust, etc)"}). + AnswerDefault() + }, + wantStdout: heredoc.Doc(` + ✓ Created directory gh-test + ✓ Initialized git repository + ✓ Set up extension scaffolding + + gh-test is ready for development! + + Next Steps + - run 'cd gh-test; gh extension install .; gh test' to see your new extension in action + - commit and use 'gh repo create' to share your extension with others + + For more information on writing extensions: + https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions + `), + }, + { + name: "create extension with arg, --precompiled=go", + args: []string{"create", "test", "--precompiled", "go"}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.CreateFunc = func(name string, tmplType extensions.ExtTemplateType) error { + return nil + } + return func(t *testing.T) { + calls := em.CreateCalls() + assert.Equal(t, 1, len(calls)) + assert.Equal(t, "gh-test", calls[0].Name) + } + }, + isTTY: true, + wantStdout: heredoc.Doc(` + ✓ Created directory gh-test + ✓ Initialized git repository + ✓ Set up extension scaffolding + ✓ Downloaded Go dependencies + ✓ Built gh-test binary + + gh-test is ready for development! + + Next Steps + - run 'cd gh-test; gh extension install .; gh test' to see your new extension in action + - use 'go build && gh test' to see changes in your code as you develop + - commit and use 'gh repo create' to share your extension with others + + For more information on writing extensions: + https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions + `), + }, + { + name: "create extension with arg, --precompiled=other", + args: []string{"create", "test", "--precompiled", "other"}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.CreateFunc = func(name string, tmplType extensions.ExtTemplateType) error { return nil } return func(t *testing.T) { @@ -241,11 +387,42 @@ func TestNewCmdExtension(t *testing.T) { ✓ Initialized git repository ✓ Set up extension scaffolding - gh-test is ready for development + gh-test is ready for development! - Install locally with: cd gh-test && gh extension install . + Next Steps + - run 'cd gh-test; gh extension install .' to install your extension locally + - fill in script/build.sh with your compilation script for automated builds + - compile a gh-test binary locally and run 'gh test' to see changes + - commit and use 'gh repo create' to share your extension with others - Publish to GitHub with: gh repo create gh-test + For more information on writing extensions: + https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions + `), + }, + { + name: "create extension tty with argument", + args: []string{"create", "test"}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.CreateFunc = func(name string, tmplType extensions.ExtTemplateType) error { + return nil + } + return func(t *testing.T) { + calls := em.CreateCalls() + assert.Equal(t, 1, len(calls)) + assert.Equal(t, "gh-test", calls[0].Name) + } + }, + isTTY: true, + wantStdout: heredoc.Doc(` + ✓ Created directory gh-test + ✓ Initialized git repository + ✓ Set up extension scaffolding + + gh-test is ready for development! + + Next Steps + - run 'cd gh-test; gh extension install .; gh test' to see your new extension in action + - commit and use 'gh repo create' to share your extension with others For more information on writing extensions: https://docs.github.com/github-cli/github-cli/creating-github-cli-extensions @@ -255,7 +432,7 @@ func TestNewCmdExtension(t *testing.T) { name: "create extension notty", args: []string{"create", "gh-test"}, managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { - em.CreateFunc = func(name string) error { + em.CreateFunc = func(name string, tmplType extensions.ExtTemplateType) error { return nil } return func(t *testing.T) { @@ -281,12 +458,24 @@ func TestNewCmdExtension(t *testing.T) { assertFunc = tt.managerStubs(em) } + as := prompt.NewAskStubber(t) + if tt.askStubs != nil { + tt.askStubs(as) + } + + reg := httpmock.Registry{} + defer reg.Verify(t) + client := http.Client{Transport: ®} + f := cmdutil.Factory{ Config: func() (config.Config, error) { return config.NewBlankConfig(), nil }, IOStreams: ios, ExtensionManager: em, + HttpClient: func() (*http.Client, error) { + return &client, nil + }, } cmd := NewCmdExtension(&f) diff --git a/pkg/cmd/extension/ext_tmpls/buildScript.sh b/pkg/cmd/extension/ext_tmpls/buildScript.sh new file mode 100644 index 000000000..df82ded26 --- /dev/null +++ b/pkg/cmd/extension/ext_tmpls/buildScript.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +echo "TODO implement this script." +echo "It should build binaries in dist/-[.exe] as needed." +exit 1 diff --git a/pkg/cmd/extension/ext_tmpls/goBinMain.go.txt b/pkg/cmd/extension/ext_tmpls/goBinMain.go.txt new file mode 100644 index 000000000..c9d2bdd43 --- /dev/null +++ b/pkg/cmd/extension/ext_tmpls/goBinMain.go.txt @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + + "github.com/cli/go-gh" +) + +func main() { + fmt.Println("hi world, this is the %s extension!") + client, err := gh.RESTClient(nil) + if err != nil { + fmt.Println(err) + return + } + response := struct {Login string}{} + err = client.Get("user", &response) + if err != nil { + fmt.Println(err) + return + } + fmt.Printf("running as %%s\n", response.Login) +} + +// For more examples of using go-gh, see: +// https://github.com/cli/go-gh/blob/trunk/example_gh_test.go diff --git a/pkg/cmd/extension/ext_tmpls/goBinWorkflow.yml b/pkg/cmd/extension/ext_tmpls/goBinWorkflow.yml new file mode 100644 index 000000000..0266208e0 --- /dev/null +++ b/pkg/cmd/extension/ext_tmpls/goBinWorkflow.yml @@ -0,0 +1,14 @@ +name: release +on: + push: + tags: + - "v*" +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: cli/gh-extension-precompile@v1 diff --git a/pkg/cmd/extension/ext_tmpls/otherBinWorkflow.yml b/pkg/cmd/extension/ext_tmpls/otherBinWorkflow.yml new file mode 100644 index 000000000..ac67c3c78 --- /dev/null +++ b/pkg/cmd/extension/ext_tmpls/otherBinWorkflow.yml @@ -0,0 +1,16 @@ +name: release +on: + push: + tags: + - "v*" +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: cli/gh-extension-precompile@v1 + with: + build_script_override: "script/build.sh" diff --git a/pkg/cmd/extension/ext_tmpls/script.sh b/pkg/cmd/extension/ext_tmpls/script.sh new file mode 100644 index 000000000..75e3dee14 --- /dev/null +++ b/pkg/cmd/extension/ext_tmpls/script.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -e + +echo "Hello %[1]s!" + +# Snippets to help get started: + +# Determine if an executable is in the PATH +# if ! type -p ruby >/dev/null; then +# echo "Ruby not found on the system" >&2 +# exit 1 +# fi + +# Pass arguments through to another command +# gh issue list "$@" -R cli/cli + +# Using the gh api command to retrieve and format information +# QUERY=' +# query($endCursor: String) { +# viewer { +# repositories(first: 100, after: $endCursor) { +# nodes { +# nameWithOwner +# stargazerCount +# } +# } +# } +# } +# ' +# TEMPLATE=' +# {{- range $repo := .data.viewer.repositories.nodes -}} +# {{- printf "name: %%s - stargazers: %%v\n" $repo.nameWithOwner $repo.stargazerCount -}} +# {{- end -}} +# ' +# exec gh api graphql -f query="${QUERY}" --paginate --template="${TEMPLATE}" diff --git a/pkg/cmd/extension/extension.go b/pkg/cmd/extension/extension.go index 502522efa..b4106228f 100644 --- a/pkg/cmd/extension/extension.go +++ b/pkg/cmd/extension/extension.go @@ -5,11 +5,22 @@ import ( "strings" ) +const manifestName = "manifest.yml" + +type ExtensionKind int + +const ( + GitKind ExtensionKind = iota + BinaryKind +) + type Extension struct { - path string - url string - isLocal bool - updateAvailable bool + path string + url string + isLocal bool + currentVersion string + latestVersion string + kind ExtensionKind } func (e *Extension) Name() string { @@ -29,5 +40,15 @@ func (e *Extension) IsLocal() bool { } func (e *Extension) UpdateAvailable() bool { - return e.updateAvailable + if e.isLocal || + e.currentVersion == "" || + e.latestVersion == "" || + e.currentVersion == e.latestVersion { + return false + } + return true +} + +func (e *Extension) IsBinary() bool { + return e.kind == BinaryKind } diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go new file mode 100644 index 000000000..cfae2b738 --- /dev/null +++ b/pkg/cmd/extension/http.go @@ -0,0 +1,114 @@ +package extension + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" +) + +func hasScript(httpClient *http.Client, repo ghrepo.Interface) (hs bool, err error) { + path := fmt.Sprintf("repos/%s/%s/contents/%s", + repo.RepoOwner(), repo.RepoName(), repo.RepoName()) + url := ghinstance.RESTPrefix(repo.RepoHost()) + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return + } + + resp, err := httpClient.Do(req) + if err != nil { + return + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return + } + + if resp.StatusCode > 299 { + err = api.HandleHTTPError(resp) + return + } + + hs = true + return +} + +type releaseAsset struct { + Name string + APIURL string `json:"url"` +} + +type release struct { + Tag string `json:"tag_name"` + Assets []releaseAsset +} + +// downloadAsset downloads a single asset to the given file path. +func downloadAsset(httpClient *http.Client, asset releaseAsset, destPath string) error { + req, err := http.NewRequest("GET", asset.APIURL, nil) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/octet-stream") + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + + f, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + return err + } + defer f.Close() + + _, err = io.Copy(f, resp.Body) + return err +} + +// fetchLatestRelease finds the latest published release for a repository. +func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*release, error) { + path := fmt.Sprintf("repos/%s/%s/releases/latest", baseRepo.RepoOwner(), baseRepo.RepoName()) + url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var r release + err = json.Unmarshal(b, &r) + if err != nil { + return nil, err + } + + return &r, nil +} diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index b488704e5..3449092cc 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -2,22 +2,30 @@ package extension import ( "bytes" + _ "embed" "errors" "fmt" "io" + "io/fs" "io/ioutil" + "net/http" "os" "os/exec" "path" "path/filepath" "runtime" "strings" + "sync" - "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/extensions" "github.com/cli/cli/v2/pkg/findsh" + "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/safeexec" + "gopkg.in/yaml.v3" ) type Manager struct { @@ -25,17 +33,37 @@ type Manager struct { lookPath func(string) (string, error) findSh func() (string, error) newCommand func(string, ...string) *exec.Cmd + platform func() (string, string) + client *http.Client + config config.Config + io *iostreams.IOStreams } -func NewManager() *Manager { +func NewManager(io *iostreams.IOStreams) *Manager { return &Manager{ dataDir: config.DataDir, lookPath: safeexec.LookPath, findSh: findsh.Find, newCommand: exec.Command, + platform: func() (string, string) { + ext := "" + if runtime.GOOS == "windows" { + ext = ".exe" + } + return fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH), ext + }, + io: io, } } +func (m *Manager) SetConfig(cfg config.Config) { + m.config = cfg +} + +func (m *Manager) SetClient(client *http.Client) { + m.client = client +} + func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) { if len(args) == 0 { return false, errors.New("too few arguments in list") @@ -46,9 +74,11 @@ func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Wri forwardArgs := args[1:] exts, _ := m.list(false) + var ext Extension for _, e := range exts { if e.Name() == extName { - exe = e.Path() + ext = e + exe = ext.Path() break } } @@ -58,7 +88,9 @@ func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Wri var externalCmd *exec.Cmd - if runtime.GOOS == "windows" { + if ext.IsBinary() || runtime.GOOS != "windows" { + externalCmd = m.newCommand(exe, forwardArgs...) + } else if runtime.GOOS == "windows" { // Dispatch all extension calls through the `sh` interpreter to support executable files with a // shebang line on Windows. shExe, err := m.findSh() @@ -70,8 +102,6 @@ func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Wri } forwardArgs = append([]string{"-c", `command "$@"`, "--", exe}, forwardArgs...) externalCmd = m.newCommand(shExe, forwardArgs...) - } else { - externalCmd = m.newCommand(exe, forwardArgs...) } externalCmd.Stdin = stdin externalCmd.Stdout = stdout @@ -81,51 +111,127 @@ func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Wri func (m *Manager) List(includeMetadata bool) []extensions.Extension { exts, _ := m.list(includeMetadata) - return exts + r := make([]extensions.Extension, len(exts)) + for i, v := range exts { + val := v + r[i] = &val + } + return r } -func (m *Manager) list(includeMetadata bool) ([]extensions.Extension, error) { +func (m *Manager) list(includeMetadata bool) ([]Extension, error) { dir := m.installDir() entries, err := ioutil.ReadDir(dir) if err != nil { return nil, err } - var results []extensions.Extension + var results []Extension for _, f := range entries { if !strings.HasPrefix(f.Name(), "gh-") { continue } - var remoteUrl string - updateAvailable := false - isLocal := false - exePath := filepath.Join(dir, f.Name(), f.Name()) + var ext Extension + var err error if f.IsDir() { - if includeMetadata { - remoteUrl = m.getRemoteUrl(f.Name()) - updateAvailable = m.checkUpdateAvailable(f.Name()) + ext, err = m.parseExtensionDir(f) + if err != nil { + return nil, err } + results = append(results, ext) } else { - isLocal = true - if !isSymlink(f.Mode()) { - // if this is a regular file, its contents is the local directory of the extension - p, err := readPathFromFile(filepath.Join(dir, f.Name())) - if err != nil { - return nil, err - } - exePath = filepath.Join(p, f.Name()) + ext, err = m.parseExtensionFile(f) + if err != nil { + return nil, err } + results = append(results, ext) } - results = append(results, &Extension{ - path: exePath, - url: remoteUrl, - isLocal: isLocal, - updateAvailable: updateAvailable, - }) } + + if includeMetadata { + m.populateLatestVersions(results) + } + return results, nil } +func (m *Manager) parseExtensionFile(fi fs.FileInfo) (Extension, error) { + ext := Extension{isLocal: true} + id := m.installDir() + exePath := filepath.Join(id, fi.Name(), fi.Name()) + if !isSymlink(fi.Mode()) { + // if this is a regular file, its contents is the local directory of the extension + p, err := readPathFromFile(filepath.Join(id, fi.Name())) + if err != nil { + return ext, err + } + exePath = filepath.Join(p, fi.Name()) + } + ext.path = exePath + return ext, nil +} + +func (m *Manager) parseExtensionDir(fi fs.FileInfo) (Extension, error) { + id := m.installDir() + if _, err := os.Stat(filepath.Join(id, fi.Name(), manifestName)); err == nil { + return m.parseBinaryExtensionDir(fi) + } + + return m.parseGitExtensionDir(fi) +} + +func (m *Manager) parseBinaryExtensionDir(fi fs.FileInfo) (Extension, error) { + id := m.installDir() + exePath := filepath.Join(id, fi.Name(), fi.Name()) + ext := Extension{path: exePath, kind: BinaryKind} + manifestPath := filepath.Join(id, fi.Name(), manifestName) + manifest, err := os.ReadFile(manifestPath) + if err != nil { + return ext, fmt.Errorf("could not open %s for reading: %w", manifestPath, err) + } + var bm binManifest + err = yaml.Unmarshal(manifest, &bm) + if err != nil { + return ext, fmt.Errorf("could not parse %s: %w", manifestPath, err) + } + repo := ghrepo.NewWithHost(bm.Owner, bm.Name, bm.Host) + remoteURL := ghrepo.GenerateRepoURL(repo, "") + ext.url = remoteURL + ext.currentVersion = bm.Tag + return ext, nil +} + +func (m *Manager) parseGitExtensionDir(fi fs.FileInfo) (Extension, error) { + id := m.installDir() + exePath := filepath.Join(id, fi.Name(), fi.Name()) + remoteUrl := m.getRemoteUrl(fi.Name()) + currentVersion := m.getCurrentVersion(fi.Name()) + return Extension{ + path: exePath, + url: remoteUrl, + isLocal: false, + currentVersion: currentVersion, + kind: GitKind, + }, nil +} + +// getCurrentVersion determines the current version for non-local git extensions. +func (m *Manager) getCurrentVersion(extension string) string { + gitExe, err := m.lookPath("git") + if err != nil { + return "" + } + dir := m.installDir() + gitDir := "--git-dir=" + filepath.Join(dir, extension, ".git") + cmd := m.newCommand(gitExe, gitDir, "rev-parse", "HEAD") + localSha, err := cmd.Output() + if err != nil { + return "" + } + return string(bytes.TrimSpace(localSha)) +} + +// getRemoteUrl determines the remote URL for non-local git extensions. func (m *Manager) getRemoteUrl(extension string) string { gitExe, err := m.lookPath("git") if err != nil { @@ -141,26 +247,59 @@ func (m *Manager) getRemoteUrl(extension string) string { return strings.TrimSpace(string(url)) } -func (m *Manager) checkUpdateAvailable(extension string) bool { - gitExe, err := m.lookPath("git") - if err != nil { - return false +func (m *Manager) populateLatestVersions(exts []Extension) { + size := len(exts) + type result struct { + index int + version string } - dir := m.installDir() - gitDir := "--git-dir=" + filepath.Join(dir, extension, ".git") - cmd := m.newCommand(gitExe, gitDir, "ls-remote", "origin", "HEAD") - lsRemote, err := cmd.Output() - if err != nil { - return false + ch := make(chan result, size) + var wg sync.WaitGroup + wg.Add(size) + for idx, ext := range exts { + go func(i int, e Extension) { + defer wg.Done() + version, _ := m.getLatestVersion(e) + ch <- result{index: i, version: version} + }(idx, ext) } - remoteSha := bytes.SplitN(lsRemote, []byte("\t"), 2)[0] - cmd = m.newCommand(gitExe, gitDir, "rev-parse", "HEAD") - localSha, err := cmd.Output() - if err != nil { - return false + wg.Wait() + close(ch) + for r := range ch { + ext := &exts[r.index] + ext.latestVersion = r.version + } +} + +func (m *Manager) getLatestVersion(ext Extension) (string, error) { + if ext.isLocal { + return "", localExtensionUpgradeError + } + if ext.IsBinary() { + repo, err := ghrepo.FromFullName(ext.url) + if err != nil { + return "", err + } + r, err := fetchLatestRelease(m.client, repo) + if err != nil { + return "", err + } + return r.Tag, nil + } else { + gitExe, err := m.lookPath("git") + if err != nil { + return "", err + } + extDir := filepath.Dir(ext.path) + gitDir := "--git-dir=" + filepath.Join(extDir, ".git") + cmd := m.newCommand(gitExe, gitDir, "ls-remote", "origin", "HEAD") + lsRemote, err := cmd.Output() + if err != nil { + return "", err + } + remoteSha := bytes.SplitN(lsRemote, []byte("\t"), 2)[0] + return string(remoteSha), nil } - localSha = bytes.TrimSpace(localSha) - return !bytes.Equal(remoteSha, localSha) } func (m *Manager) InstallLocal(dir string) error { @@ -172,7 +311,104 @@ func (m *Manager) InstallLocal(dir string) error { return makeSymlink(dir, targetLink) } -func (m *Manager) Install(cloneURL string, stdout, stderr io.Writer) error { +type binManifest struct { + Owner string + Name string + Host string + Tag string + // TODO I may end up not using this; just thinking ahead to local installs + Path string +} + +func (m *Manager) Install(repo ghrepo.Interface) error { + isBin, err := isBinExtension(m.client, repo) + if err != nil { + return fmt.Errorf("could not check for binary extension: %w", err) + } + if isBin { + return m.installBin(repo) + } + + hs, err := hasScript(m.client, repo) + if err != nil { + return err + } + if !hs { + return errors.New("extension is not installable: missing executable") + } + + protocol, _ := m.config.GetOrDefault(repo.RepoHost(), "git_protocol") + return m.installGit(ghrepo.FormatRemoteURL(repo, protocol), m.io.Out, m.io.ErrOut) +} + +func (m *Manager) installBin(repo ghrepo.Interface) error { + var r *release + r, err := fetchLatestRelease(m.client, repo) + if err != nil { + return err + } + + platform, ext := m.platform() + var asset *releaseAsset + for _, a := range r.Assets { + if strings.HasSuffix(a.Name, platform+ext) { + asset = &a + break + } + } + + if asset == nil { + return fmt.Errorf( + "%[1]s unsupported for %[2]s. Open an issue: `gh issue create -R %[3]s/%[1]s -t'Support %[2]s'`", + repo.RepoName(), platform, repo.RepoOwner()) + } + + name := repo.RepoName() + targetDir := filepath.Join(m.installDir(), name) + // TODO clean this up if function errs? + err = os.MkdirAll(targetDir, 0755) + if err != nil { + return fmt.Errorf("failed to create installation directory: %w", err) + } + + binPath := filepath.Join(targetDir, name) + binPath += ext + + err = downloadAsset(m.client, *asset, binPath) + if err != nil { + return fmt.Errorf("failed to download asset %s: %w", asset.Name, err) + } + + manifest := binManifest{ + Name: name, + Owner: repo.RepoOwner(), + Host: repo.RepoHost(), + Path: binPath, + Tag: r.Tag, + } + + bs, err := yaml.Marshal(manifest) + if err != nil { + return fmt.Errorf("failed to serialize manifest: %w", err) + } + + manifestPath := filepath.Join(targetDir, manifestName) + + f, err := os.OpenFile(manifestPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to open manifest for writing: %w", err) + } + defer f.Close() + + _, err = f.Write(bs) + if err != nil { + return fmt.Errorf("failed write manifest file: %w", err) + } + + return nil +} + +func (m *Manager) installGit(cloneURL string, stdout, stderr io.Writer) error { exe, err := m.lookPath("git") if err != nil { return err @@ -188,54 +424,108 @@ func (m *Manager) Install(cloneURL string, stdout, stderr io.Writer) error { } var localExtensionUpgradeError = errors.New("local extensions can not be upgraded") +var upToDateError = errors.New("already up to date") +var noExtensionsInstalledError = errors.New("no extensions installed") -func (m *Manager) Upgrade(name string, force bool, stdout, stderr io.Writer) error { +func (m *Manager) Upgrade(name string, force bool) error { + // Fetch metadata during list only when upgrading all extensions. + // This is a performance improvement so that we don't make a + // bunch of unecessary network requests when trying to upgrade a single extension. + fetchMetadata := name == "" + exts, _ := m.list(fetchMetadata) + if len(exts) == 0 { + return noExtensionsInstalledError + } + if name == "" { + return m.upgradeExtensions(exts, force) + } + for _, f := range exts { + if f.Name() != name { + continue + } + var err error + // For single extensions manually retrieve latest version since we forgo + // doing it during list. + f.latestVersion, err = m.getLatestVersion(f) + if err != nil { + return err + } + return m.upgradeExtension(f, force) + } + return fmt.Errorf("no extension matched %q", name) +} + +func (m *Manager) upgradeExtensions(exts []Extension, force bool) error { + var failed bool + for _, f := range exts { + fmt.Fprintf(m.io.Out, "[%s]: ", f.Name()) + err := m.upgradeExtension(f, force) + if err != nil { + if !errors.Is(err, localExtensionUpgradeError) && + !errors.Is(err, upToDateError) { + failed = true + } + fmt.Fprintf(m.io.Out, "%s\n", err) + continue + } + fmt.Fprintf(m.io.Out, "upgrade complete\n") + } + if failed { + return errors.New("some extensions failed to upgrade") + } + return nil +} + +func (m *Manager) upgradeExtension(ext Extension, force bool) error { + if ext.isLocal { + return localExtensionUpgradeError + } + if !ext.UpdateAvailable() { + return upToDateError + } + var err error + if ext.IsBinary() { + err = m.upgradeBinExtension(ext) + } else { + // Check if git extension has changed to a binary extension + var isBin bool + repo, repoErr := repoFromPath(filepath.Join(ext.Path(), "..")) + if repoErr == nil { + isBin, _ = isBinExtension(m.client, repo) + } + if isBin { + err = m.Remove(ext.Name()) + if err != nil { + return fmt.Errorf("failed to migrate to new precompiled extension format: %w", err) + } + return m.installBin(repo) + } + err = m.upgradeGitExtension(ext, force) + } + return err +} + +func (m *Manager) upgradeGitExtension(ext Extension, force bool) error { exe, err := m.lookPath("git") if err != nil { return err } - - exts := m.List(false) - if len(exts) == 0 { - return errors.New("no extensions installed") + dir := filepath.Dir(ext.path) + if force { + if err := m.newCommand(exe, "-C", dir, "fetch", "origin", "HEAD").Run(); err != nil { + return err + } + return m.newCommand(exe, "-C", dir, "reset", "--hard", "origin/HEAD").Run() } + return m.newCommand(exe, "-C", dir, "pull", "--ff-only").Run() +} - someUpgraded := false - for _, f := range exts { - if name == "" { - fmt.Fprintf(stdout, "[%s]: ", f.Name()) - } else if f.Name() != name { - continue - } - - if f.IsLocal() { - if name == "" { - fmt.Fprintf(stdout, "%s\n", localExtensionUpgradeError) - } else { - err = localExtensionUpgradeError - } - continue - } - - var cmds []*exec.Cmd - dir := filepath.Dir(f.Path()) - if force { - fetchCmd := m.newCommand(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "fetch", "origin", "HEAD") - resetCmd := m.newCommand(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "reset", "--hard", "origin/HEAD") - cmds = []*exec.Cmd{fetchCmd, resetCmd} - } else { - pullCmd := m.newCommand(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "pull", "--ff-only") - cmds = []*exec.Cmd{pullCmd} - } - if e := runCmds(cmds, stdout, stderr); e != nil { - err = e - } - someUpgraded = true +func (m *Manager) upgradeBinExtension(ext Extension) error { + repo, err := ghrepo.FromFullName(ext.url) + if err != nil { + return fmt.Errorf("failed to parse URL %s: %w", ext.url, err) } - if err == nil && !someUpgraded { - err = fmt.Errorf("no extension matched %q", name) - } - return err + return m.installBin(repo) } func (m *Manager) Remove(name string) error { @@ -250,91 +540,119 @@ func (m *Manager) installDir() string { return filepath.Join(m.dataDir(), "extensions") } -func (m *Manager) Create(name string) error { +//go:embed ext_tmpls/goBinMain.go.txt +var mainGoTmpl string + +//go:embed ext_tmpls/goBinWorkflow.yml +var goBinWorkflow []byte + +//go:embed ext_tmpls/otherBinWorkflow.yml +var otherBinWorkflow []byte + +//go:embed ext_tmpls/script.sh +var scriptTmpl string + +//go:embed ext_tmpls/buildScript.sh +var buildScript []byte + +func (m *Manager) Create(name string, tmplType extensions.ExtTemplateType) error { exe, err := m.lookPath("git") if err != nil { return err } - err = os.Mkdir(name, 0755) - if err != nil { + if err := m.newCommand(exe, "init", "--quiet", name).Run(); err != nil { return err } - initCmd := m.newCommand(exe, "init", "--quiet", name) - err = initCmd.Run() - if err != nil { + if tmplType == extensions.GoBinTemplateType { + return m.goBinScaffolding(exe, name) + } else if tmplType == extensions.OtherBinTemplateType { + return m.otherBinScaffolding(exe, name) + } + + script := fmt.Sprintf(scriptTmpl, name) + if err := writeFile(filepath.Join(name, name), []byte(script), 0755); err != nil { return err } - fileTmpl := heredoc.Docf(` - #!/usr/bin/env bash - set -e - - echo "Hello %[1]s!" - - # Snippets to help get started: - - # Determine if an executable is in the PATH - # if ! type -p ruby >/dev/null; then - # echo "Ruby not found on the system" >&2 - # exit 1 - # fi - - # Pass arguments through to another command - # gh issue list "$@" -R cli/cli - - # Using the gh api command to retrieve and format information - # QUERY=' - # query($endCursor: String) { - # viewer { - # repositories(first: 100, after: $endCursor) { - # nodes { - # nameWithOwner - # stargazerCount - # } - # } - # } - # } - # ' - # TEMPLATE=' - # {{- range $repo := .data.viewer.repositories.nodes -}} - # {{- printf "name: %[2]s - stargazers: %[3]s\n" $repo.nameWithOwner $repo.stargazerCount -}} - # {{- end -}} - # ' - # exec gh api graphql -f query="${QUERY}" --paginate --template="${TEMPLATE}" - `, name, "%s", "%v") - filePath := filepath.Join(name, name) - err = ioutil.WriteFile(filePath, []byte(fileTmpl), 0755) - if err != nil { - return err - } - - wd, err := os.Getwd() - if err != nil { - return err - } - dir := filepath.Join(wd, name) - addCmd := m.newCommand(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "add", name, "--chmod=+x") - err = addCmd.Run() - return err + return m.newCommand(exe, "-C", name, "add", name, "--chmod=+x").Run() } -func runCmds(cmds []*exec.Cmd, stdout, stderr io.Writer) error { - for _, cmd := range cmds { - cmd.Stdout = stdout - cmd.Stderr = stderr - if err := cmd.Run(); err != nil { - return err +func (m *Manager) otherBinScaffolding(gitExe, name string) error { + if err := writeFile(filepath.Join(name, ".github", "workflows", "release.yml"), otherBinWorkflow, 0644); err != nil { + return err + } + buildScriptPath := filepath.Join("script", "build.sh") + if err := writeFile(filepath.Join(name, buildScriptPath), buildScript, 0755); err != nil { + return err + } + if err := m.newCommand(gitExe, "-C", name, "add", buildScriptPath, "--chmod=+x").Run(); err != nil { + return err + } + return m.newCommand(gitExe, "-C", name, "add", ".").Run() +} + +func (m *Manager) goBinScaffolding(gitExe, name string) error { + goExe, err := m.lookPath("go") + if err != nil { + return fmt.Errorf("go is required for creating Go extensions: %w", err) + } + + if err := writeFile(filepath.Join(name, ".github", "workflows", "release.yml"), goBinWorkflow, 0644); err != nil { + return err + } + + mainGo := fmt.Sprintf(mainGoTmpl, name) + if err := writeFile(filepath.Join(name, "main.go"), []byte(mainGo), 0644); err != nil { + return err + } + + host, err := m.config.DefaultHost() + if err != nil { + return err + } + + currentUser, err := api.CurrentLoginName(api.NewClientFromHTTP(m.client), host) + if err != nil { + return err + } + + goCmds := [][]string{ + {"mod", "init", fmt.Sprintf("%s/%s/%s", host, currentUser, name)}, + {"mod", "tidy"}, + {"build"}, + } + + ignore := fmt.Sprintf("/%[1]s\n/%[1]s.exe\n", name) + if err := writeFile(filepath.Join(name, ".gitignore"), []byte(ignore), 0644); err != nil { + return err + } + + for _, args := range goCmds { + goCmd := m.newCommand(goExe, args...) + goCmd.Dir = name + if err := goCmd.Run(); err != nil { + return fmt.Errorf("failed to set up go module: %w", err) } } - return nil + + return m.newCommand(gitExe, "-C", name, "add", ".").Run() } func isSymlink(m os.FileMode) bool { return m&os.ModeSymlink != 0 } +func writeFile(p string, contents []byte, mode os.FileMode) error { + if dir := filepath.Dir(p); dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + } + return os.WriteFile(p, contents, mode) +} + // reads the product of makeSymlink on Windows func readPathFromFile(path string) (string, error) { f, err := os.Open(path) @@ -346,3 +664,107 @@ func readPathFromFile(path string) (string, error) { n, err := f.Read(b) return strings.TrimSpace(string(b[:n])), err } + +func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err error) { + var r *release + r, err = fetchLatestRelease(client, repo) + if err != nil { + httpErr, ok := err.(api.HTTPError) + if ok && httpErr.StatusCode == 404 { + err = nil + return + } + return + } + + for _, a := range r.Assets { + dists := possibleDists() + for _, d := range dists { + suffix := d + if strings.HasPrefix(d, "windows") { + suffix += ".exe" + } + if strings.HasSuffix(a.Name, suffix) { + isBin = true + break + } + } + } + + return +} + +func repoFromPath(path string) (ghrepo.Interface, error) { + remotes, err := git.RemotesForPath(path) + if err != nil { + return nil, err + } + + if len(remotes) == 0 { + return nil, fmt.Errorf("no remotes configured for %s", path) + } + + var remote *git.Remote + + for _, r := range remotes { + if r.Name == "origin" { + remote = r + break + } + } + + if remote == nil { + remote = remotes[0] + } + + return ghrepo.FromURL(remote.FetchURL) +} + +func possibleDists() []string { + return []string{ + "aix-ppc64", + "android-386", + "android-amd64", + "android-arm", + "android-arm64", + "darwin-amd64", + "darwin-arm64", + "dragonfly-amd64", + "freebsd-386", + "freebsd-amd64", + "freebsd-arm", + "freebsd-arm64", + "illumos-amd64", + "ios-amd64", + "ios-arm64", + "js-wasm", + "linux-386", + "linux-amd64", + "linux-arm", + "linux-arm64", + "linux-mips", + "linux-mips64", + "linux-mips64le", + "linux-mipsle", + "linux-ppc64", + "linux-ppc64le", + "linux-riscv64", + "linux-s390x", + "netbsd-386", + "netbsd-amd64", + "netbsd-arm", + "netbsd-arm64", + "openbsd-386", + "openbsd-amd64", + "openbsd-arm", + "openbsd-arm64", + "openbsd-mips64", + "plan9-386", + "plan9-amd64", + "plan9-arm", + "solaris-amd64", + "windows-386", + "windows-amd64", + "windows-arm", + } +} diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index 2fd458bf1..47f11d348 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -4,14 +4,25 @@ import ( "bytes" "fmt" "io/ioutil" + "net/http" "os" "os/exec" "path/filepath" "runtime" + "sort" + "strings" "testing" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/internal/config" + "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/internal/run" + "github.com/cli/cli/v2/pkg/extensions" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/cli/cli/v2/pkg/iostreams" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) func TestHelperProcess(t *testing.T) { @@ -19,6 +30,15 @@ func TestHelperProcess(t *testing.T) { return } if err := func(args []string) error { + // git init should create the directory named by argument + if len(args) > 2 && strings.HasPrefix(strings.Join(args, " "), "git init") { + dir := args[len(args)-1] + if !strings.HasPrefix(dir, "-") { + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + } + } fmt.Fprintf(os.Stdout, "%v\n", args) return nil }(os.Args[3:]); err != nil { @@ -28,7 +48,7 @@ func TestHelperProcess(t *testing.T) { os.Exit(0) } -func newTestManager(dir string) *Manager { +func newTestManager(dir string, client *http.Client, io *iostreams.IOStreams) *Manager { return &Manager{ dataDir: func() string { return dir }, lookPath: func(exe string) (string, error) { return exe, nil }, @@ -36,9 +56,19 @@ func newTestManager(dir string) *Manager { newCommand: func(exe string, args ...string) *exec.Cmd { args = append([]string{os.Args[0], "-test.run=TestHelperProcess", "--", exe}, args...) cmd := exec.Command(args[0], args[1:]...) + if io != nil { + cmd.Stdout = io.Out + cmd.Stderr = io.ErrOut + } cmd.Env = []string{"GH_WANT_HELPER_PROCESS=1"} return cmd }, + config: config.NewBlankConfig(), + io: io, + client: client, + platform: func() (string, string) { + return "windows-amd64", ".exe" + }, } } @@ -47,11 +77,59 @@ func TestManager_List(t *testing.T) { assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello"))) assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-two", "gh-two"))) - m := newTestManager(tempDir) + assert.NoError(t, stubBinaryExtension( + filepath.Join(tempDir, "extensions", "gh-bin-ext"), + binManifest{ + Owner: "owner", + Name: "gh-bin-ext", + Host: "example.com", + Tag: "v1.0.1", + })) + + m := newTestManager(tempDir, nil, nil) exts := m.List(false) - assert.Equal(t, 2, len(exts)) - assert.Equal(t, "hello", exts[0].Name()) - assert.Equal(t, "two", exts[1].Name()) + assert.Equal(t, 3, len(exts)) + assert.Equal(t, "bin-ext", exts[0].Name()) + assert.Equal(t, "hello", exts[1].Name()) + assert.Equal(t, "two", exts[2].Name()) +} + +func TestManager_List_binary_update(t *testing.T) { + tempDir := t.TempDir() + + assert.NoError(t, stubBinaryExtension( + filepath.Join(tempDir, "extensions", "gh-bin-ext"), + binManifest{ + Owner: "owner", + Name: "gh-bin-ext", + Host: "example.com", + Tag: "v1.0.1", + })) + + reg := httpmock.Registry{} + defer reg.Verify(t) + client := http.Client{Transport: ®} + + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Tag: "v1.0.2", + Assets: []releaseAsset{ + { + Name: "gh-bin-ext-windows-amd64", + APIURL: "https://example.com/release/cool2", + }, + }, + })) + + m := newTestManager(tempDir, &client, nil) + + exts := m.List(true) + assert.Equal(t, 1, len(exts)) + assert.Equal(t, "bin-ext", exts[0].Name()) + assert.True(t, exts[0].UpdateAvailable()) + assert.Equal(t, "https://example.com/owner/gh-bin-ext", exts[0].URL()) } func TestManager_Dispatch(t *testing.T) { @@ -59,7 +137,7 @@ func TestManager_Dispatch(t *testing.T) { extPath := filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello") assert.NoError(t, stubExtension(extPath)) - m := newTestManager(tempDir) + m := newTestManager(tempDir, nil, nil) stdout := &bytes.Buffer{} stderr := &bytes.Buffer{} @@ -75,12 +153,36 @@ func TestManager_Dispatch(t *testing.T) { assert.Equal(t, "", stderr.String()) } +func TestManager_Dispatch_binary(t *testing.T) { + tempDir := t.TempDir() + extPath := filepath.Join(tempDir, "extensions", "gh-hello") + exePath := filepath.Join(extPath, "gh-hello") + bm := binManifest{ + Owner: "owner", + Name: "gh-hello", + Host: "github.com", + Tag: "v1.0.0", + } + assert.NoError(t, stubBinaryExtension(extPath, bm)) + + m := newTestManager(tempDir, nil, nil) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + found, err := m.Dispatch([]string{"hello", "one", "two"}, nil, stdout, stderr) + assert.NoError(t, err) + assert.True(t, found) + + assert.Equal(t, fmt.Sprintf("[%s one two]\n", exePath), stdout.String()) + assert.Equal(t, "", stderr.String()) +} + func TestManager_Remove(t *testing.T) { tempDir := t.TempDir() assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello"))) assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-two", "gh-two"))) - m := newTestManager(tempDir) + m := newTestManager(tempDir, nil, nil) err := m.Remove("hello") assert.NoError(t, err) @@ -90,136 +192,503 @@ func TestManager_Remove(t *testing.T) { assert.Equal(t, "gh-two", items[0].Name()) } -func TestManager_Upgrade_AllExtensions(t *testing.T) { - tempDir := t.TempDir() - assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello"))) - assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-two", "gh-two"))) - assert.NoError(t, stubLocalExtension(tempDir, filepath.Join(tempDir, "extensions", "gh-local", "gh-local"))) - - m := newTestManager(tempDir) - - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - err := m.Upgrade("", false, stdout, stderr) - assert.NoError(t, err) - - assert.Equal(t, heredoc.Docf( - ` - [hello]: [git -C %s --git-dir=%s pull --ff-only] - [local]: local extensions can not be upgraded - [two]: [git -C %s --git-dir=%s pull --ff-only] - `, - filepath.Join(tempDir, "extensions", "gh-hello"), - filepath.Join(tempDir, "extensions", "gh-hello", ".git"), - filepath.Join(tempDir, "extensions", "gh-two"), - filepath.Join(tempDir, "extensions", "gh-two", ".git"), - ), stdout.String()) - assert.Equal(t, "", stderr.String()) -} - -func TestManager_Upgrade_RemoteExtension(t *testing.T) { - tempDir := t.TempDir() - assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-remote", "gh-remote"))) - - m := newTestManager(tempDir) - - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - err := m.Upgrade("remote", false, stdout, stderr) - assert.NoError(t, err) - assert.Equal(t, heredoc.Docf( - ` - [git -C %s --git-dir=%s pull --ff-only] - `, - filepath.Join(tempDir, "extensions", "gh-remote"), - filepath.Join(tempDir, "extensions", "gh-remote", ".git"), - ), stdout.String()) - assert.Equal(t, "", stderr.String()) -} - -func TestManager_Upgrade_LocalExtension(t *testing.T) { - tempDir := t.TempDir() - assert.NoError(t, stubLocalExtension(tempDir, filepath.Join(tempDir, "extensions", "gh-local", "gh-local"))) - - m := newTestManager(tempDir) - - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - err := m.Upgrade("local", false, stdout, stderr) - assert.EqualError(t, err, "local extensions can not be upgraded") - assert.Equal(t, "", stdout.String()) - assert.Equal(t, "", stderr.String()) -} - -func TestManager_Upgrade_Force(t *testing.T) { - tempDir := t.TempDir() - extensionDir := filepath.Join(tempDir, "extensions", "gh-remote") - gitDir := filepath.Join(tempDir, "extensions", "gh-remote", ".git") - - assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-remote", "gh-remote"))) - - m := newTestManager(tempDir) - - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - err := m.Upgrade("remote", true, stdout, stderr) - assert.NoError(t, err) - assert.Equal(t, heredoc.Docf( - ` - [git -C %s --git-dir=%s fetch origin HEAD] - [git -C %s --git-dir=%s reset --hard origin/HEAD] - `, - extensionDir, - gitDir, - extensionDir, - gitDir, - ), stdout.String()) - assert.Equal(t, "", stderr.String()) -} - func TestManager_Upgrade_NoExtensions(t *testing.T) { tempDir := t.TempDir() - - m := newTestManager(tempDir) - - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - err := m.Upgrade("", false, stdout, stderr) + io, _, stdout, stderr := iostreams.Test() + m := newTestManager(tempDir, nil, io) + err := m.Upgrade("", false) assert.EqualError(t, err, "no extensions installed") assert.Equal(t, "", stdout.String()) assert.Equal(t, "", stderr.String()) } -func TestManager_Install(t *testing.T) { +func TestManager_Upgrade_NoMatchingExtension(t *testing.T) { tempDir := t.TempDir() - m := newTestManager(tempDir) + assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello"))) + io, _, stdout, stderr := iostreams.Test() + m := newTestManager(tempDir, nil, io) + err := m.Upgrade("invalid", false) + assert.EqualError(t, err, `no extension matched "invalid"`) + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) +} - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - err := m.Install("https://github.com/owner/gh-some-ext.git", stdout, stderr) +func TestManager_UpgradeExtensions(t *testing.T) { + tempDir := t.TempDir() + assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-hello", "gh-hello"))) + assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-two", "gh-two"))) + assert.NoError(t, stubLocalExtension(tempDir, filepath.Join(tempDir, "extensions", "gh-local", "gh-local"))) + io, _, stdout, stderr := iostreams.Test() + m := newTestManager(tempDir, nil, io) + exts, err := m.list(false) + assert.NoError(t, err) + assert.Equal(t, 3, len(exts)) + for i := 0; i < 3; i++ { + exts[i].currentVersion = "old version" + exts[i].latestVersion = "new version" + } + err = m.upgradeExtensions(exts, false) + assert.NoError(t, err) + assert.Equal(t, heredoc.Docf( + ` + [hello]: [git -C %s pull --ff-only] + upgrade complete + [local]: local extensions can not be upgraded + [two]: [git -C %s pull --ff-only] + upgrade complete + `, + filepath.Join(tempDir, "extensions", "gh-hello"), + filepath.Join(tempDir, "extensions", "gh-two"), + ), stdout.String()) + assert.Equal(t, "", stderr.String()) +} + +func TestManager_UpgradeExtension_LocalExtension(t *testing.T) { + tempDir := t.TempDir() + assert.NoError(t, stubLocalExtension(tempDir, filepath.Join(tempDir, "extensions", "gh-local", "gh-local"))) + + io, _, stdout, stderr := iostreams.Test() + m := newTestManager(tempDir, nil, io) + exts, err := m.list(false) + assert.NoError(t, err) + assert.Equal(t, 1, len(exts)) + err = m.upgradeExtension(exts[0], false) + assert.EqualError(t, err, "local extensions can not be upgraded") + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) +} + +func TestManager_UpgradeExtension_GitExtension(t *testing.T) { + tempDir := t.TempDir() + assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-remote", "gh-remote"))) + io, _, stdout, stderr := iostreams.Test() + m := newTestManager(tempDir, nil, io) + exts, err := m.list(false) + assert.NoError(t, err) + assert.Equal(t, 1, len(exts)) + ext := exts[0] + ext.currentVersion = "old version" + ext.latestVersion = "new version" + err = m.upgradeExtension(ext, false) + assert.NoError(t, err) + assert.Equal(t, heredoc.Docf( + ` + [git -C %s pull --ff-only] + `, + filepath.Join(tempDir, "extensions", "gh-remote"), + ), stdout.String()) + assert.Equal(t, "", stderr.String()) +} + +func TestManager_UpgradeExtension_GitExtension_Force(t *testing.T) { + tempDir := t.TempDir() + extensionDir := filepath.Join(tempDir, "extensions", "gh-remote") + assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-remote", "gh-remote"))) + io, _, stdout, stderr := iostreams.Test() + m := newTestManager(tempDir, nil, io) + exts, err := m.list(false) + assert.NoError(t, err) + assert.Equal(t, 1, len(exts)) + ext := exts[0] + ext.currentVersion = "old version" + ext.latestVersion = "new version" + err = m.upgradeExtension(ext, true) + assert.NoError(t, err) + assert.Equal(t, heredoc.Docf( + ` + [git -C %[1]s fetch origin HEAD] + [git -C %[1]s reset --hard origin/HEAD] + `, + extensionDir, + ), stdout.String()) + assert.Equal(t, "", stderr.String()) +} + +func TestManager_MigrateToBinaryExtension(t *testing.T) { + tempDir := t.TempDir() + assert.NoError(t, stubExtension(filepath.Join(tempDir, "extensions", "gh-remote", "gh-remote"))) + io, _, stdout, stderr := iostreams.Test() + + reg := httpmock.Registry{} + defer reg.Verify(t) + client := http.Client{Transport: ®} + m := newTestManager(tempDir, &client, io) + exts, err := m.list(false) + assert.NoError(t, err) + assert.Equal(t, 1, len(exts)) + ext := exts[0] + ext.currentVersion = "old version" + ext.latestVersion = "new version" + + rs, restoreRun := run.Stub() + defer restoreRun(t) + + rs.Register(`git -C.*?gh-remote remote -v`, 0, "origin git@github.com:owner/gh-remote.git (fetch)\norigin git@github.com:owner/gh-remote.git (push)") + rs.Register(`git -C.*?gh-remote config --get-regexp \^.*`, 0, "remote.origin.gh-resolve base") + + reg.Register( + httpmock.REST("GET", "repos/owner/gh-remote/releases/latest"), + httpmock.JSONResponse( + release{ + Tag: "v1.0.2", + Assets: []releaseAsset{ + { + Name: "gh-remote-windows-amd64.exe", + APIURL: "/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/owner/gh-remote/releases/latest"), + httpmock.JSONResponse( + release{ + Tag: "v1.0.2", + Assets: []releaseAsset{ + { + Name: "gh-remote-windows-amd64.exe", + APIURL: "/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "release/cool"), + httpmock.StringResponse("FAKE UPGRADED BINARY")) + + err = m.upgradeExtension(ext, false) + assert.NoError(t, err) + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) + + manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-remote", manifestName)) + assert.NoError(t, err) + + var bm binManifest + err = yaml.Unmarshal(manifest, &bm) + assert.NoError(t, err) + + assert.Equal(t, binManifest{ + Name: "gh-remote", + Owner: "owner", + Host: "github.com", + Tag: "v1.0.2", + Path: filepath.Join(tempDir, "extensions/gh-remote/gh-remote.exe"), + }, bm) + + fakeBin, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-remote/gh-remote.exe")) + assert.NoError(t, err) + + assert.Equal(t, "FAKE UPGRADED BINARY", string(fakeBin)) +} + +func TestManager_UpgradeExtension_BinaryExtension(t *testing.T) { + tempDir := t.TempDir() + + reg := httpmock.Registry{} + defer reg.Verify(t) + + assert.NoError(t, stubBinaryExtension( + filepath.Join(tempDir, "extensions", "gh-bin-ext"), + binManifest{ + Owner: "owner", + Name: "gh-bin-ext", + Host: "example.com", + Tag: "v1.0.1", + })) + + io, _, stdout, stderr := iostreams.Test() + m := newTestManager(tempDir, &http.Client{Transport: ®}, io) + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Tag: "v1.0.2", + Assets: []releaseAsset{ + { + Name: "gh-bin-ext-windows-amd64.exe", + APIURL: "https://example.com/release/cool2", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "release/cool2"), + httpmock.StringResponse("FAKE UPGRADED BINARY")) + + exts, err := m.list(false) + assert.NoError(t, err) + assert.Equal(t, 1, len(exts)) + ext := exts[0] + ext.latestVersion = "v1.0.2" + err = m.upgradeExtension(ext, false) + assert.NoError(t, err) + + manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext", manifestName)) + assert.NoError(t, err) + + var bm binManifest + err = yaml.Unmarshal(manifest, &bm) + assert.NoError(t, err) + + assert.Equal(t, binManifest{ + Name: "gh-bin-ext", + Owner: "owner", + Host: "example.com", + Tag: "v1.0.2", + Path: filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe"), + }, bm) + + fakeBin, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe")) + assert.NoError(t, err) + assert.Equal(t, "FAKE UPGRADED BINARY", string(fakeBin)) + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) +} + +func TestManager_Install_git(t *testing.T) { + tempDir := t.TempDir() + + reg := httpmock.Registry{} + defer reg.Verify(t) + client := http.Client{Transport: ®} + + io, _, stdout, stderr := iostreams.Test() + m := newTestManager(tempDir, &client, io) + + reg.Register( + httpmock.REST("GET", "repos/owner/gh-some-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Assets: []releaseAsset{ + { + Name: "not-a-binary", + APIURL: "https://example.com/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/owner/gh-some-ext/contents/gh-some-ext"), + httpmock.StringResponse("script")) + + repo := ghrepo.New("owner", "gh-some-ext") + + err := m.Install(repo) assert.NoError(t, err) assert.Equal(t, fmt.Sprintf("[git clone https://github.com/owner/gh-some-ext.git %s]\n", filepath.Join(tempDir, "extensions", "gh-some-ext")), stdout.String()) assert.Equal(t, "", stderr.String()) } -func TestManager_Create(t *testing.T) { +func TestManager_Install_binary_unsupported(t *testing.T) { + repo := ghrepo.NewWithHost("owner", "gh-bin-ext", "example.com") + + reg := httpmock.Registry{} + defer reg.Verify(t) + client := http.Client{Transport: ®} + + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Assets: []releaseAsset{ + { + Name: "gh-bin-ext-linux-amd64", + APIURL: "https://example.com/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Tag: "v1.0.1", + Assets: []releaseAsset{ + { + Name: "gh-bin-ext-linux-amd64", + APIURL: "https://example.com/release/cool", + }, + }, + })) + + io, _, stdout, stderr := iostreams.Test() tempDir := t.TempDir() + + m := newTestManager(tempDir, &client, io) + + err := m.Install(repo) + assert.EqualError(t, err, "gh-bin-ext unsupported for windows-amd64. Open an issue: `gh issue create -R owner/gh-bin-ext -t'Support windows-amd64'`") + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) +} + +func TestManager_Install_binary(t *testing.T) { + repo := ghrepo.NewWithHost("owner", "gh-bin-ext", "example.com") + + reg := httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Assets: []releaseAsset{ + { + Name: "gh-bin-ext-windows-amd64.exe", + APIURL: "https://example.com/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Tag: "v1.0.1", + Assets: []releaseAsset{ + { + Name: "gh-bin-ext-windows-amd64.exe", + APIURL: "https://example.com/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "release/cool"), + httpmock.StringResponse("FAKE BINARY")) + + io, _, stdout, stderr := iostreams.Test() + tempDir := t.TempDir() + + m := newTestManager(tempDir, &http.Client{Transport: ®}, io) + + err := m.Install(repo) + assert.NoError(t, err) + + manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext", manifestName)) + assert.NoError(t, err) + + var bm binManifest + err = yaml.Unmarshal(manifest, &bm) + assert.NoError(t, err) + + assert.Equal(t, binManifest{ + Name: "gh-bin-ext", + Owner: "owner", + Host: "example.com", + Tag: "v1.0.1", + Path: filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe"), + }, bm) + + fakeBin, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe")) + assert.NoError(t, err) + assert.Equal(t, "FAKE BINARY", string(fakeBin)) + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) +} + +func TestManager_Create(t *testing.T) { + chdirTemp(t) + io, _, stdout, stderr := iostreams.Test() + m := newTestManager(".", nil, io) + + err := m.Create("gh-test", extensions.GitTemplateType) + assert.NoError(t, err) + files, err := ioutil.ReadDir("gh-test") + assert.NoError(t, err) + assert.Equal(t, []string{"gh-test"}, fileNames(files)) + + assert.Equal(t, heredoc.Doc(` + [git init --quiet gh-test] + [git -C gh-test add gh-test --chmod=+x] + `), stdout.String()) + assert.Equal(t, "", stderr.String()) +} + +func TestManager_Create_go_binary(t *testing.T) { + chdirTemp(t) + reg := httpmock.Registry{} + defer reg.Verify(t) + reg.Register( + httpmock.GraphQL(`query UserCurrent\b`), + httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`)) + + io, _, stdout, stderr := iostreams.Test() + m := newTestManager(".", &http.Client{Transport: ®}, io) + + err := m.Create("gh-test", extensions.GoBinTemplateType) + require.NoError(t, err) + + files, err := ioutil.ReadDir("gh-test") + require.NoError(t, err) + assert.Equal(t, []string{".github", ".gitignore", "main.go"}, fileNames(files)) + + gitignore, err := os.ReadFile(filepath.Join("gh-test", ".gitignore")) + require.NoError(t, err) + assert.Equal(t, heredoc.Doc(` + /gh-test + /gh-test.exe + `), string(gitignore)) + + files, err = ioutil.ReadDir(filepath.Join("gh-test", ".github", "workflows")) + require.NoError(t, err) + assert.Equal(t, []string{"release.yml"}, fileNames(files)) + + assert.Equal(t, heredoc.Doc(` + [git init --quiet gh-test] + [go mod init github.com/jillv/gh-test] + [go mod tidy] + [go build] + [git -C gh-test add .] + `), stdout.String()) + assert.Equal(t, "", stderr.String()) +} + +func TestManager_Create_other_binary(t *testing.T) { + chdirTemp(t) + io, _, stdout, stderr := iostreams.Test() + m := newTestManager(".", nil, io) + + err := m.Create("gh-test", extensions.OtherBinTemplateType) + assert.NoError(t, err) + + files, err := ioutil.ReadDir("gh-test") + assert.NoError(t, err) + assert.Equal(t, 2, len(files)) + + files, err = ioutil.ReadDir(filepath.Join("gh-test", ".github", "workflows")) + assert.NoError(t, err) + assert.Equal(t, []string{"release.yml"}, fileNames(files)) + + files, err = ioutil.ReadDir(filepath.Join("gh-test", "script")) + assert.NoError(t, err) + assert.Equal(t, []string{"build.sh"}, fileNames(files)) + + assert.Equal(t, heredoc.Docf(` + [git init --quiet gh-test] + [git -C gh-test add %s --chmod=+x] + [git -C gh-test add .] + `, filepath.FromSlash("script/build.sh")), stdout.String()) + assert.Equal(t, "", stderr.String()) +} + +// chdirTemp changes the current working directory to a temporary directory for the duration of the test. +func chdirTemp(t *testing.T) { oldWd, _ := os.Getwd() - assert.NoError(t, os.Chdir(tempDir)) - t.Cleanup(func() { _ = os.Chdir(oldWd) }) - m := newTestManager(tempDir) - err := m.Create("gh-test") - assert.NoError(t, err) - files, err := ioutil.ReadDir(filepath.Join(tempDir, "gh-test")) - assert.NoError(t, err) - assert.Equal(t, 1, len(files)) - extFile := files[0] - assert.Equal(t, "gh-test", extFile.Name()) - if runtime.GOOS == "windows" { - assert.Equal(t, os.FileMode(0666), extFile.Mode()) - } else { - assert.Equal(t, os.FileMode(0755), extFile.Mode()) + tempDir := t.TempDir() + if err := os.Chdir(tempDir); err != nil { + t.Fatal(err) } + t.Cleanup(func() { + _ = os.Chdir(oldWd) + }) +} + +func fileNames(files []os.FileInfo) []string { + names := make([]string, len(files)) + for i, f := range files { + names[i] = f.Name() + } + sort.Strings(names) + return names } func stubExtension(path string) error { @@ -260,3 +729,37 @@ func stubLocalExtension(tempDir, path string) error { } return f.Close() } + +// Given the path where an extension should be installed and a manifest struct, creates a fake binary extension on disk +func stubBinaryExtension(installPath string, bm binManifest) error { + if err := os.MkdirAll(installPath, 0755); err != nil { + return err + } + fakeBinaryPath := filepath.Join(installPath, filepath.Base(installPath)) + fb, err := os.OpenFile(fakeBinaryPath, os.O_CREATE, 0755) + if err != nil { + return err + } + err = fb.Close() + if err != nil { + return err + } + + bs, err := yaml.Marshal(bm) + if err != nil { + return fmt.Errorf("failed to serialize manifest: %w", err) + } + + manifestPath := filepath.Join(installPath, manifestName) + + fm, err := os.OpenFile(manifestPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return fmt.Errorf("failed to open manifest for writing: %w", err) + } + _, err = fm.Write(bs) + if err != nil { + return fmt.Errorf("failed write manifest file: %w", err) + } + + return fm.Close() +} diff --git a/pkg/cmd/extension/symlink_other.go b/pkg/cmd/extension/symlink_other.go index f426d031c..59c1989d9 100644 --- a/pkg/cmd/extension/symlink_other.go +++ b/pkg/cmd/extension/symlink_other.go @@ -1,3 +1,4 @@ +//go:build !windows // +build !windows package extension diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 961883b12..b89d28e21 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "os" + "time" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/context" @@ -18,11 +19,9 @@ import ( func New(appVersion string) *cmdutil.Factory { f := &cmdutil.Factory{ - Config: configFunc(), // No factory dependencies - Branch: branchFunc(), // No factory dependencies - Executable: executable(), // No factory dependencies - - ExtensionManager: extension.NewManager(), + Config: configFunc(), // No factory dependencies + Branch: branchFunc(), // No factory dependencies + ExecutableName: "gh", } f.IOStreams = ioStreams(f) // Depends on Config @@ -30,6 +29,7 @@ func New(appVersion string) *cmdutil.Factory { f.Remotes = remotesFunc(f) // Depends on Config f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes f.Browser = browser(f) // Depends on Config, and IOStreams + f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams return f } @@ -113,14 +113,6 @@ func browserLauncher(f *cmdutil.Factory) string { return os.Getenv("BROWSER") } -func executable() string { - gh := "gh" - if exe, err := os.Executable(); err == nil { - gh = exe - } - return gh -} - func configFunc() func() (config.Config, error) { var cachedConfig config.Config var configError error @@ -148,6 +140,25 @@ func branchFunc() func() (string, error) { } } +func extensionManager(f *cmdutil.Factory) *extension.Manager { + em := extension.NewManager(f.IOStreams) + + cfg, err := f.Config() + if err != nil { + return em + } + em.SetConfig(cfg) + + client, err := f.HttpClient() + if err != nil { + return em + } + + em.SetClient(api.NewCachedClient(client, time.Second*30)) + + return em +} + func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams { io := iostreams.System() cfg, err := f.Config() @@ -155,7 +166,7 @@ func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams { return io } - if prompt, _ := cfg.Get("", "prompt"); prompt == "disabled" { + if prompt, _ := cfg.GetOrDefault("", "prompt"); prompt == "disabled" { io.SetNeverPrompt(true) } diff --git a/pkg/cmd/factory/http.go b/pkg/cmd/factory/http.go index fd61f2dc7..7037b1558 100644 --- a/pkg/cmd/factory/http.go +++ b/pkg/cmd/factory/http.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "os" + "regexp" "strings" "time" @@ -107,6 +108,7 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg configGetter, appVersion string, } return "", nil }), + api.ExtractHeader("X-GitHub-SSO", &ssoHeader), ) if setAccept { @@ -126,6 +128,22 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg configGetter, appVersion string, return api.NewHTTPClient(opts...), nil } +var ssoHeader string +var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`) + +// SSOURL returns the URL of a SAML SSO challenge received by the server for clients that use ExtractHeader +// to extract the value of the "X-GitHub-SSO" response header. +func SSOURL() string { + if ssoHeader == "" { + return "" + } + m := ssoURLRE.FindStringSubmatch(ssoHeader) + if m == nil { + return "" + } + return m[1] +} + func getHost(r *http.Request) string { if r.Host != "" { return r.Host diff --git a/pkg/cmd/factory/http_test.go b/pkg/cmd/factory/http_test.go index 1505d1b65..0cb5ac15c 100644 --- a/pkg/cmd/factory/http_test.go +++ b/pkg/cmd/factory/http_test.go @@ -25,8 +25,10 @@ func TestNewHTTPClient(t *testing.T) { args args envDebug string host string + sso string wantHeader map[string]string wantStderr string + wantSSO string }{ { name: "github.com with Accept header", @@ -95,10 +97,10 @@ func TestNewHTTPClient(t *testing.T) { > Accept: application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview > Authorization: token ████████████████████ > User-Agent: GitHub CLI v1.2.3 - + < HTTP/1.1 204 No Content < Date: