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 58930656a..047fb52ea 100644 --- a/.github/workflows/prauto.yml +++ b/.github/workflows/prauto.yml @@ -45,6 +45,10 @@ jobs: 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 @@ -56,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 f44689804..b95c51715 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -25,6 +25,8 @@ jobs: -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: @@ -33,6 +35,8 @@ jobs: 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: @@ -61,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 @@ -129,23 +132,19 @@ jobs: unzip -o *.zip && rm -v *.zip env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} - - name: Install go-msi - run: choco install -y "go-msi" - name: Prepare PATH - shell: bash - run: | - echo "$WIX\\bin" >> $GITHUB_PATH - echo "C:\\Program Files\\go-msi" >> $GITHUB_PATH + id: setupmsbuild + uses: microsoft/setup-msbuild@v1.0.3 - name: Build MSI id: buildmsi shell: bash env: ZIP_FILE: ${{ steps.download_exe.outputs.zip }} + MSBUILD_PATH: ${{ steps.setupmsbuild.outputs.msbuildPath }} run: | - mkdir -p build - msi="$(basename "$ZIP_FILE" ".zip").msi" - printf "::set-output name=msi::%s\n" "$msi" - go-msi make --msi "$PWD/$msi" --out "$PWD/build" --version "${GITHUB_REF#refs/tags/}" + name="$(basename "$ZIP_FILE" ".zip")" + version="$(echo -e ${GITHUB_REF#refs/tags/v} | sed s/-.*$//)" + "${MSBUILD_PATH}\MSBuild.exe" ./build/windows/gh.wixproj -p:SourceDir="$PWD" -p:OutputPath="$PWD" -p:OutputName="$name" -p:ProductVersion="$version" - name: Obtain signing cert id: obtain_cert env: 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 index 0f505885f..4b9029313 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,5 +1,5 @@ * @cli/code-reviewers -pkg/cmd/codespace/* @cli/codespaces -pkg/liveshare/* @cli/codespaces -internal/codespaces/* @cli/codespaces +pkg/cmd/codespace/ @cli/codespaces +pkg/liveshare/ @cli/codespaces +internal/codespaces/ @cli/codespaces diff --git a/api/client.go b/api/client.go index e3e48f57d..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,9 +162,31 @@ 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 @@ -173,7 +222,7 @@ func (err HTTPError) ScopesSuggestion() string { // 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 { + if resp.StatusCode < 400 || resp.StatusCode > 499 || resp.StatusCode == 422 { return "" } @@ -221,7 +270,8 @@ func EndpointNeedsScopes(resp *http.Response, s string) *http.Response { return resp } -// GraphQL performs a GraphQL request and parses the response +// 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 { diff --git a/api/client_test.go b/api/client_test.go index c7848d242..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()) } } @@ -200,6 +208,11 @@ func TestHTTPError_ScopesSuggestion(t *testing.T) { 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) { diff --git a/api/export_pr.go b/api/export_pr.go index 18bce025b..3c4d14078 100644 --- a/api/export_pr.go +++ b/api/export_pr.go @@ -75,6 +75,8 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} { data[f] = pr.ProjectCards.Nodes case "reviews": data[f] = pr.Reviews.Nodes + case "latestReviews": + data[f] = pr.LatestReviews.Nodes case "files": data[f] = pr.Files.Nodes case "reviewRequests": 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 d09497569..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 { @@ -22,6 +20,7 @@ type IssuesAndTotalCount struct { } type Issue struct { + Typename string `json:"__typename"` ID string Number int Title string @@ -41,6 +40,10 @@ type Issue struct { ReactionGroups ReactionGroups } +func (i Issue) IsPullRequest() bool { + return i.Typename == "PullRequest" +} + type Assignees struct { Nodes []GitHubUser TotalCount int @@ -68,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 { @@ -230,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 744b4c9e2..19337512b 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -64,7 +64,8 @@ type PullRequest struct { BaseRef struct { BranchProtectionRule struct { - RequiresStrictStatusChecks bool + RequiresStrictStatusChecks bool + RequiredApprovingReviewCount int } } @@ -108,6 +109,7 @@ type PullRequest struct { Comments Comments ReactionGroups ReactionGroups Reviews PullRequestReviews + LatestReviews PullRequestReviews ReviewRequests ReviewRequests } @@ -405,6 +407,11 @@ func PullRequestStatus(client *Client, repo ghrepo.Interface, options StatusOpti } pullRequest(number: $number) { ...prWithReviews + baseRef { + branchProtectionRule { + requiredApprovingReviewCount + } + } } } ` @@ -519,7 +526,7 @@ func pullRequestFragment(httpClient *http.Client, hostname string) (string, erro var reviewFields []string if prFeatures.HasReviewDecision { - reviewFields = append(reviewFields, "reviewDecision") + reviewFields = append(reviewFields, "reviewDecision", "latestReviews") } fragments := fmt.Sprintf(` @@ -621,20 +628,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 { @@ -660,7 +653,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 { @@ -671,17 +664,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 { @@ -692,14 +683,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 8832aae7d..1c8a210fb 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -524,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 { @@ -741,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 } @@ -952,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...) @@ -963,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 @@ -1108,46 +1137,6 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo return milestones, nil } -func MilestoneByTitle(client *Client, repo ghrepo.Interface, state, title string) (*RepoMilestone, error) { - milestones, err := RepoMilestones(client, repo, state) - if err != nil { - return nil, err - } - - for i := range milestones { - if strings.EqualFold(milestones[i].Title, title) { - return &milestones[i], nil - } - } - return nil, fmt.Errorf("no milestone found with title %q", title) -} - -func MilestoneByNumber(client *Client, repo ghrepo.Interface, number int32) (*RepoMilestone, error) { - var query struct { - Repository struct { - Milestone *RepoMilestone `graphql:"milestone(number: $number)"` - } `graphql:"repository(owner: $owner, name: $name)"` - } - - variables := map[string]interface{}{ - "owner": githubv4.String(repo.RepoOwner()), - "name": githubv4.String(repo.RepoName()), - "number": githubv4.Int(number), - } - - gql := graphQLClient(client.http, repo.RepoHost()) - - err := gql.QueryNamed(context.Background(), "RepositoryMilestoneByNumber", &query, variables) - if err != nil { - return nil, err - } - if query.Repository.Milestone == nil { - return nil, fmt.Errorf("no milestone found with number '%d'", number) - } - - return query.Repository.Milestone, nil -} - func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string) ([]string, error) { var paths []string projects, err := RepoAndOrgProjects(client, repo) diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go index 8846e16cc..5091e656f 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -23,7 +23,7 @@ func TestGitHubRepo_notFound(t *testing.T) { if err == nil { t.Fatal("GitHubRepo did not return an error") } - if wants := "GraphQL error: Could not resolve to a Repository with the name 'OWNER/REPO'."; err.Error() != wants { + 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 { @@ -362,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..977089df6 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,19 @@ var prReviews = shortenQuery(` reactionGroups{content,users{totalCount}} } pageInfo{hasNextPage,endCursor} + totalCount + } +`) + +var prLatestReviews = shortenQuery(` + latestReviews(first: 100) { + nodes { + author{login}, + authorAssociation, + submittedAt, + body, + state + } } `) @@ -163,6 +192,7 @@ var PullRequestFields = append(IssueFields, "headRepositoryOwner", "isCrossRepository", "isDraft", + "latestReviews", "maintainerCanModify", "mergeable", "mergeCommit", @@ -176,6 +206,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,10 +236,14 @@ 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": q = append(q, prReviews) + case "latestReviews": + q = append(q, prLatestReviews) case "files": q = append(q, prFiles) case "commits": diff --git a/build/windows/gh.wixproj b/build/windows/gh.wixproj new file mode 100644 index 000000000..aa72d4da3 --- /dev/null +++ b/build/windows/gh.wixproj @@ -0,0 +1,38 @@ + + + + Release + x64 + 0.1.0 + $(MSBuildProjectName) + package + $([MSBuild]::NormalizeDirectory($(MSBuildProjectDirectory)\..\..)) + $(RepoPath)bin\$(Platform)\ + $(RepoPath)bin\obj\$(Platform)\ + + $(DefineConstants); + ProductVersion=$(ProductVersion); + + ICE39 + false + $(MSBuildExtensionsPath)\Microsoft\WiX\v3.x\Wix.targets + + + + + + + + + + + + + + + + + + + + diff --git a/build/windows/gh.wxs b/build/windows/gh.wxs new file mode 100644 index 000000000..1e91734f1 --- /dev/null +++ b/build/windows/gh.wxs @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/build/windows/ui.wxs b/build/windows/ui.wxs new file mode 100644 index 000000000..8c534adc8 --- /dev/null +++ b/build/windows/ui.wxs @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + 1 + "1"]]> + + 1 + + NOT Installed + Installed AND PATCH + + 1 + 1 + NOT WIXUI_DONTVALIDATEPATH + "1"]]> + WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1" + 1 + 1 + + NOT Installed + Installed AND NOT PATCH + Installed AND PATCH + + 1 + + 1 + 1 + 1 + + + + + + + \ No newline at end of file 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 50f8335a3..10cd94c2e 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -224,8 +224,9 @@ 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) } 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/git/git.go b/git/git.go index 97dafa1c7..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,16 @@ 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 { 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/go.mod b/go.mod index 93feba312..adf42c900 100644 --- a/go.mod +++ b/go.mod @@ -5,44 +5,42 @@ go 1.16 require ( github.com/AlecAivazis/survey/v2 v2.3.2 github.com/MakeNowJust/heredoc v1.0.0 - github.com/briandowns/spinner v1.13.0 - 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.9.0 github.com/cli/safeexec v1.0.0 + 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.6 + github.com/google/go-cmp v0.5.7 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 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.5 + 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.11 + 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/microcosm-cc/bluemonday v1.0.16 // indirect - github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5 + 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/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 + github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a // indirect github.com/sourcegraph/jsonrpc2 v0.1.0 - github.com/spf13/cobra v1.2.1 + 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-20210711020723-a769d52b0f97 + 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-20210927094055-39ccf1dd6fa6 + 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 a82789ee4..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= @@ -41,35 +50,44 @@ github.com/AlecAivazis/survey/v2 v2.3.2 h1:TqTB+aDDCLYhf9/bD2TwSO8u8jDSmMUd2SUVO 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.13.0 h1:q/Y9LtpwtvL0CRzXrAMj0keVXqNhBYUFg6tBOUiY8ek= -github.com/briandowns/spinner v1.13.0/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= @@ -79,48 +97,65 @@ 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/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 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/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ= -github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk= 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/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= @@ -129,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= @@ -147,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= @@ -160,12 +197,14 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ 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/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= 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= @@ -177,66 +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/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/graph-gophers/graphql-go v0.0.0-20200622220639-c1d9693c95a6/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= 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.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw= github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90= 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.5 h1:6SJ1BQ1VAwJAlIvLSIZmqHP/RUEq3qfVWvsRxrqhsD0= -github.com/itchyny/gojq v0.12.5/go.mod h1:3e1hZXv+Kwvdp6V9HXpVrvddiHVApi5EDZwS+zLFeiE= +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= @@ -244,90 +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.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs= -github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +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/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/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/go.mod h1:HOT/6NaBlR0f9XlxD3zolN6Z3N8Lp4pvhp+jLS5ihnI= -github.com/microcosm-cc/bluemonday v1.0.16 h1:kHmAq2t7WPWLjiGvzKa5o3HzSfahUKiOq7fAPUiMNIc= -github.com/microcosm-cc/bluemonday v1.0.16/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= +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/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/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/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= -github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA= -github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= -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= @@ -340,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= @@ -359,6 +441,7 @@ 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= @@ -397,19 +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-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= @@ -431,11 +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/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 h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q= 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= @@ -447,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= @@ -463,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= @@ -499,6 +598,7 @@ 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= @@ -507,11 +607,20 @@ golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7w 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-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-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo= +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= @@ -522,8 +631,9 @@ 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/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= 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= @@ -533,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= @@ -541,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= @@ -577,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= @@ -604,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= @@ -653,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= @@ -673,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= @@ -685,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 bfe9d1299..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() @@ -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 index 8f2124d8c..8de16bbb8 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -45,25 +45,40 @@ import ( "github.com/opentracing/opentracing-go" ) -const githubAPI = "https://api.github.com" +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 { - token string - client httpClient - githubAPI string + client httpClient + vscsAPI string + githubAPI string + githubServer string } type httpClient interface { Do(req *http.Request) (*http.Response, error) } -// New creates a new API client with the given token and HTTP client. -func New(token string, httpClient httpClient) *API { +// 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{ - token: token, - client: httpClient, - githubAPI: githubAPI, + client: httpClient, + vscsAPI: strings.TrimSuffix(vscsURL, "/"), + githubAPI: strings.TrimSuffix(apiURL, "/"), + githubServer: strings.TrimSuffix(serverURL, "/"), } } @@ -386,7 +401,7 @@ type getCodespaceRegionLocationResponse struct { // GetCodespaceRegionLocation returns the closest codespace location for the user. func (a *API) GetCodespaceRegionLocation(ctx context.Context) (string, error) { - req, err := http.NewRequest(http.MethodGet, "https://online.visualstudio.com/api/v1/locations", nil) + req, err := http.NewRequest(http.MethodGet, a.vscsAPI+"/api/v1/locations", nil) if err != nil { return "", fmt.Errorf("error creating request: %w", err) } @@ -415,8 +430,9 @@ func (a *API) GetCodespaceRegionLocation(ctx context.Context) (string, error) { } type Machine struct { - Name string `json:"name"` - DisplayName string `json:"display_name"` + 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. @@ -460,14 +476,17 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc // CreateCodespaceParams are the required parameters for provisioning a Codespace. type CreateCodespaceParams struct { - RepositoryID int - Branch, Machine, Location string + 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.RepositoryID, params.Machine, params.Branch, params.Location) + codespace, err := a.startCreate(ctx, params) if err != errProvisioningInProgress { return codespace, err } @@ -502,10 +521,11 @@ func (a *API) CreateCodespace(ctx context.Context, params *CreateCodespaceParams } type startCreateRequest struct { - RepositoryID int `json:"repository_id"` - Ref string `json:"ref"` - Location string `json:"location"` - Machine string `json:"machine"` + 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") @@ -514,8 +534,18 @@ var errProvisioningInProgress = errors.New("provisioning in progress") // 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, repoID int, machine, branch, location string) (*Codespace, error) { - requestBody, err := json.Marshal(startCreateRequest{repoID, branch, location, machine}) +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) } @@ -620,7 +650,7 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod // 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("https://github.com/%s.keys", user) + url := fmt.Sprintf("%s/%s.keys", a.githubServer, user) req, err := http.NewRequest(http.MethodGet, url, nil) if err != nil { return nil, err @@ -654,8 +684,5 @@ func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http // setHeaders sets the required headers for the API. func (a *API) setHeaders(req *http.Request) { - if a.token != "" { - req.Header.Set("Authorization", "Bearer "+a.token) - } 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 index e8748fa9a..6dcd06a04 100644 --- a/internal/codespaces/api/api_test.go +++ b/internal/codespaces/api/api_test.go @@ -72,7 +72,6 @@ func TestListCodespaces_limited(t *testing.T) { api := API{ githubAPI: svr.URL, client: &http.Client{}, - token: "faketoken", } ctx := context.TODO() codespaces, err := api.ListCodespaces(ctx, 200) @@ -98,7 +97,6 @@ func TestListCodespaces_unlimited(t *testing.T) { api := API{ githubAPI: svr.URL, client: &http.Client{}, - token: "faketoken", } ctx := context.TODO() codespaces, err := api.ListCodespaces(ctx, -1) diff --git a/internal/codespaces/states.go b/internal/codespaces/states.go index ca5ae49e7..688fd063f 100644 --- a/internal/codespaces/states.go +++ b/internal/codespaces/states.go @@ -43,7 +43,7 @@ func PollPostCreateStates(ctx context.Context, progress progressIndicator, apiCl session, err := ConnectToLiveshare(ctx, progress, noopLogger, apiClient, codespace) if err != nil { - return fmt.Errorf("connect to Live Share: %w", err) + return fmt.Errorf("connect to codespace: %w", err) } defer func() { if closeErr := session.Close(); err == 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 dd1df2e9b..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 146a94e77..b96852a4d 100644 --- a/internal/ghinstance/host.go +++ b/internal/ghinstance/host.go @@ -72,13 +72,23 @@ func RESTPrefix(hostname string) string { } func GistPrefix(hostname string) string { + prefix := "https://" + + if strings.EqualFold(hostname, localhost) { + prefix = "http://" + } + + return prefix + GistHost(hostname) +} + +func GistHost(hostname string) string { if IsEnterprise(hostname) { - return fmt.Sprintf("https://%s/gist/", hostname) + return fmt.Sprintf("%s/gist/", hostname) } if strings.EqualFold(hostname, localhost) { - return fmt.Sprintf("http://%s/gist/", hostname) + return fmt.Sprintf("%s/gist/", hostname) } - return fmt.Sprintf("https://gist.%s/", hostname) + return fmt.Sprintf("gist.%s/", hostname) } func HostPrefix(hostname string) string { diff --git a/internal/ghrepo/repo.go b/internal/ghrepo/repo.go index f16de0842..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) } @@ -105,7 +111,9 @@ func IsSame(a, b Interface) bool { func GenerateRepoURL(repo Interface, p string, args ...interface{}) string { 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 } 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 94100d820..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,6 +170,9 @@ 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") @@ -393,6 +399,9 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream 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 } @@ -540,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 408b74674..04da8cc29 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -1284,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 f591fcbc6..51e74861d 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -68,37 +68,34 @@ 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.FlagErrorf("--web or --with-token required when not running interactively") - } - if tokenStdin && opts.Web { - return cmdutil.FlagErrorf("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.FlagErrorf("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() @@ -125,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 f6fbcabd5..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, }, }, { @@ -147,8 +170,7 @@ func Test_NewCmdLogin(t *testing.T) { t.Run(tt.name, func(t *testing.T) { io, stdin, _, _ := iostreams.Test() f := &cmdutil.Factory{ - IOStreams: io, - Executable: func() string { return "/path/to/gh" }, + IOStreams: io, } io.SetStdoutTTY(true) @@ -346,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 @@ -371,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, @@ -390,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, "") @@ -419,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, "") @@ -450,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, "") @@ -474,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"), }, @@ -524,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_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 f2b9cbbb4..4ef58f73c 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -26,7 +26,7 @@ type RefreshOptions struct { 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 } @@ -35,8 +35,8 @@ 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 }, httpClient: http.DefaultClient, @@ -146,7 +146,7 @@ func refreshRun(opts *RefreshOptions) error { credentialFlow := &shared.GitCredentialFlow{ Executable: opts.MainExecutable, } - gitProtocol, _ := cfg.Get(hostname, "git_protocol") + gitProtocol, _ := cfg.GetOrDefault(hostname, "git_protocol") if opts.Interactive && gitProtocol == "https" { if err := credentialFlow.Prompt(hostname); err != nil { return err @@ -154,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 dbdae26af..1bee8435d 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -91,8 +91,7 @@ func Test_NewCmdRefresh(t *testing.T) { t.Run(tt.name, func(t *testing.T) { io, _, _, _ := iostreams.Test() f := &cmdutil.Factory{ - IOStreams: io, - Executable: func() string { return "/path/to/gh" }, + IOStreams: io, } io.SetStdinTTY(tt.tty) io.SetStdoutTTY(tt.tty) @@ -195,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", @@ -233,7 +232,7 @@ func Test_refreshRun(t *testing.T) { 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 @@ -277,8 +276,7 @@ func Test_refreshRun(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/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 1a8b2747c..fb8ba31c2 100644 --- a/pkg/cmd/auth/shared/git_credential.go +++ b/pkg/cmd/auth/shared/git_credential.go @@ -63,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 @@ -121,7 +142,8 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s } func gitCredentialHelperKey(hostname string) string { - return fmt.Sprintf("credential.%s.helper", strings.TrimSuffix(ghinstance.HostPrefix(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/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 8bd64940d..7d29f9723 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -3,6 +3,8 @@ package browse import ( "fmt" "net/http" + "net/url" + "path" "path/filepath" "strconv" "strings" @@ -27,6 +29,7 @@ type BrowseOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams PathFromRepoRoot func() string + GitClient gitClient SelectorArg string @@ -44,6 +47,7 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co HttpClient: f.HttpClient, IO: f.IOStreams, PathFromRepoRoot: git.PathFromRepoRoot, + GitClient: &localGitClient{}, } cmd := &cobra.Command{ @@ -95,6 +99,9 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co ); err != nil { return err } + if cmd.Flags().Changed("repo") { + opts.GitClient = &remoteGitClient{opts.BaseRepo, opts.HttpClient} + } if runF != nil { return runF(opts) @@ -121,17 +128,18 @@ func runBrowse(opts *BrowseOptions) error { } if opts.CommitFlag { - commit, err := git.LastCommit() - if err == nil { - opts.Branch = commit.Sha + commit, err := opts.GitClient.LastCommit() + if err != nil { + return err } + opts.Branch = commit.Sha } section, err := parseSection(baseRepo, opts) if err != nil { return err } - url := ghrepo.GenerateRepoURL(baseRepo, section) + url := ghrepo.GenerateRepoURL(baseRepo, "%s", section) if opts.NoBrowserFlag { _, err := fmt.Fprintln(opts.IO.Out, url) @@ -186,23 +194,30 @@ func parseSection(baseRepo ghrepo.Interface, opts *BrowseOptions) (string, error } else { rangeFragment = fmt.Sprintf("L%d", rangeStart) } - return fmt.Sprintf("blob/%s/%s?plain=1#%s", branchName, filePath, rangeFragment), nil + return fmt.Sprintf("blob/%s/%s?plain=1#%s", escapePath(branchName), escapePath(filePath), rangeFragment), nil } - return fmt.Sprintf("tree/%s/%s", branchName, filePath), 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 = parts[0] - if !filepath.IsAbs(p) { - p = filepath.Clean(filepath.Join(opts.PathFromRepoRoot(), p)) - // Ensure that a path using \ can be used in a URL - p = strings.ReplaceAll(p, "\\", "/") + p = filepath.ToSlash(parts[0]) + if !path.IsAbs(p) { + p = path.Join(opts.PathFromRepoRoot(), p) if p == "." || strings.HasPrefix(p, "..") { p = "" } @@ -236,3 +251,33 @@ 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 489ad4e09..36e0ed778 100644 --- a/pkg/cmd/browse/browse_test.go +++ b/pkg/cmd/browse/browse_test.go @@ -2,6 +2,7 @@ package browse import ( "fmt" + "io/ioutil" "net/http" "os" "path/filepath" @@ -125,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 { @@ -140,6 +143,7 @@ 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) }) } } @@ -153,6 +157,12 @@ func setGitDir(t *testing.T, dir string) { }) } +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") @@ -217,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", @@ -228,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{ @@ -322,16 +361,18 @@ func Test_runBrowse(t *testing.T) { 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/", + 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, @@ -363,6 +404,16 @@ func Test_runBrowse(t *testing.T) { 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, + }, } for _, tt := range tests { @@ -410,67 +461,100 @@ func Test_runBrowse(t *testing.T) { } func Test_parsePathFromFileArg(t *testing.T) { - s := string(os.PathSeparator) tests := []struct { name string + currentDir string fileArg string expectedPath string }{ + { + name: "empty paths", + currentDir: "", + fileArg: "", + expectedPath: "", + }, + { + name: "root directory", + currentDir: "", + fileArg: ".", + expectedPath: "", + }, + { + name: "relative path", + currentDir: "", + fileArg: filepath.FromSlash("foo/bar.py"), + expectedPath: "foo/bar.py", + }, { name: "go to parent folder", - fileArg: ".." + s, + 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)", - fileArg: "." + s, + 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", - fileArg: filepath.Join("..", ".") + s + s + s + "browse.go", + 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", - fileArg: filepath.Join("..", "..", "..", "..", "..", "..") + s + "", + currentDir: "pkg/cmd/browse/", + fileArg: filepath.FromSlash("../../../../../../"), expectedPath: "", }, { name: "go to root of repository", - fileArg: filepath.Join("..", "..", "..") + s + "", + currentDir: "pkg/cmd/browse/", + fileArg: filepath.Join("../../../"), + expectedPath: "", + }, + { + name: "empty fileArg", + fileArg: "", expectedPath: "", }, } for _, tt := range tests { path, _, _, _ := parseFile(BrowseOptions{ PathFromRepoRoot: func() string { - return "pkg/cmd/browse/" + 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 index 17b658963..2c66b4655 100644 --- a/pkg/cmd/codespace/code.go +++ b/pkg/cmd/codespace/code.go @@ -3,12 +3,17 @@ package codespace import ( "context" "fmt" + "io/ioutil" "net/url" - "github.com/skratchdot/open-golang/open" + "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 @@ -20,7 +25,8 @@ func newCodeCmd(app *App) *cobra.Command { Short: "Open a codespace in Visual Studio Code", Args: noArgsConstraint, RunE: func(cmd *cobra.Command, args []string) error { - return app.VSCode(cmd.Context(), codespace, useInsiders) + b := cmdutil.NewBrowser("", ioutil.Discard, app.io.ErrOut) + return app.VSCode(cmd.Context(), b, codespace, useInsiders) }, } @@ -31,7 +37,7 @@ func newCodeCmd(app *App) *cobra.Command { } // VSCode opens a codespace in the local VS VSCode application. -func (a *App) VSCode(ctx context.Context, codespaceName string, useInsiders bool) error { +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 { @@ -44,8 +50,8 @@ func (a *App) VSCode(ctx context.Context, codespaceName string, useInsiders bool } url := vscodeProtocolURL(codespaceName, useInsiders) - if err := open.Run(url); err != nil { - return fmt.Errorf("error opening vscode URL %s: %s. (Is Visual Studio Code installed?)", url, err) + if err := browser.Browse(url); err != nil { + return fmt.Errorf("error opening Visual Studio Code: %w", err) } return nil 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 index 0f6f9cf4e..1107ae6a5 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -21,19 +21,25 @@ import ( "golang.org/x/term" ) -type App struct { - io *iostreams.IOStreams - apiClient apiClient - errLogger *log.Logger +type executable interface { + Executable() string } -func NewApp(io *iostreams.IOStreams, apiClient apiClient) *App { +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, + io: io, + apiClient: apiClient, + errLogger: errLogger, + executable: exe, } } @@ -209,8 +215,13 @@ func ask(qs []*survey.Question, response interface{}) error { // 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, user string) error { - keys, err := client.AuthorizedKeys(ctx, user) +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) } diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go index ac74658c3..ee9d81fe7 100644 --- a/pkg/cmd/codespace/create.go +++ b/pkg/cmd/codespace/create.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "time" "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/v2/internal/codespaces" @@ -12,10 +13,11 @@ import ( ) type createOptions struct { - repo string - branch string - machine string - showStatus bool + repo string + branch string + machine string + showStatus bool + idleTimeout time.Duration } func newCreateCmd(app *App) *cobra.Command { @@ -34,6 +36,7 @@ func newCreateCmd(app *App) *cobra.Command { 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 } @@ -101,10 +104,11 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { a.StartProgressIndicatorWithLabel("Creating codespace") codespace, err := a.apiClient.CreateCodespace(ctx, &api.CreateCodespaceParams{ - RepositoryID: repository.ID, - Branch: branch, - Machine: machine, - Location: locationResult.Location, + RepositoryID: repository.ID, + Branch: branch, + Machine: machine, + Location: locationResult.Location, + IdleTimeoutMinutes: int(opts.idleTimeout.Minutes()), }) a.StopProgressIndicator() if err != nil { @@ -234,7 +238,7 @@ func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machin machineNames := make([]string, 0, len(machines)) machineByName := make(map[string]*api.Machine) for _, m := range machines { - machineName := m.DisplayName + machineName := buildDisplayName(m.DisplayName, m.PrebuildAvailability) machineNames = append(machineNames, machineName) machineByName[machineName] = m } @@ -260,3 +264,14 @@ func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machin 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_test.go b/pkg/cmd/codespace/delete_test.go index 58090c809..89318b5e3 100644 --- a/pkg/cmd/codespace/delete_test.go +++ b/pkg/cmd/codespace/delete_test.go @@ -190,7 +190,7 @@ func TestDelete(t *testing.T) { io, _, stdout, stderr := iostreams.Test() io.SetStdinTTY(true) io.SetStdoutTTY(true) - app := NewApp(io, apiMock) + 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) diff --git a/pkg/cmd/codespace/logs.go b/pkg/cmd/codespace/logs.go index c42ef42d7..d0a0c233b 100644 --- a/pkg/cmd/codespace/logs.go +++ b/pkg/cmd/codespace/logs.go @@ -41,19 +41,14 @@ func (a *App) Logs(ctx context.Context, codespaceName string, follow bool) (err return fmt.Errorf("get or choose codespace: %w", err) } - user, err := a.apiClient.GetUser(ctx) - if err != nil { - return fmt.Errorf("getting user: %w", err) - } - authkeys := make(chan error, 1) go func() { - authkeys <- checkAuthorizedKeys(ctx, a.apiClient, user.Login) + authkeys <- checkAuthorizedKeys(ctx, a.apiClient) }() session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace) if err != nil { - return fmt.Errorf("connecting to Live Share: %w", err) + return fmt.Errorf("connecting to codespace: %w", err) } defer safeClose(session, &err) diff --git a/pkg/cmd/codespace/ports.go b/pkg/cmd/codespace/ports.go index ff07b059c..094833e30 100644 --- a/pkg/cmd/codespace/ports.go +++ b/pkg/cmd/codespace/ports.go @@ -59,7 +59,7 @@ func (a *App) ListPorts(ctx context.Context, codespaceName string, exporter cmdu session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace) if err != nil { - return fmt.Errorf("error connecting to Live Share: %w", err) + return fmt.Errorf("error connecting to codespace: %w", err) } defer safeClose(session, &err) @@ -245,7 +245,7 @@ func (a *App) UpdatePortVisibility(ctx context.Context, codespaceName string, ar session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace) if err != nil { - return fmt.Errorf("error connecting to Live Share: %w", err) + return fmt.Errorf("error connecting to codespace: %w", err) } defer safeClose(session, &err) @@ -321,7 +321,7 @@ func (a *App) ForwardPorts(ctx context.Context, codespaceName string, ports []st session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace) if err != nil { - return fmt.Errorf("error connecting to Live Share: %w", err) + return fmt.Errorf("error connecting to codespace: %w", err) } defer safeClose(session, &err) diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go index 9c0ae7f1b..726f2152f 100644 --- a/pkg/cmd/codespace/ssh.go +++ b/pkg/cmd/codespace/ssh.go @@ -4,15 +4,21 @@ package codespace 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" @@ -24,6 +30,8 @@ type sshOptions struct { serverPort int debug bool debugFile string + stdio bool + config bool scpArgs []string // scp arguments, for 'cs cp' (nil for 'cs ssh') } @@ -33,8 +41,57 @@ func newSSHCmd(app *App) *cobra.Command { 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 { - return app.SSH(cmd.Context(), args, opts) + if opts.config { + return app.printOpenSSHConfig(cmd.Context(), opts) + } else { + return app.SSH(cmd.Context(), args, opts) + } }, DisableFlagsInUseLine: true, } @@ -44,6 +101,11 @@ func newSSHCmd(app *App) *cobra.Command { 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 } @@ -54,23 +116,18 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e 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) } - // TODO(josebalius): We can fetch the user in parallel to everything else - // we should convert this call and others to happen async - user, err := a.apiClient.GetUser(ctx) - if err != nil { - return fmt.Errorf("error getting user: %w", err) - } - - authkeys := make(chan error, 1) - go func() { - authkeys <- checkAuthorizedKeys(ctx, a.apiClient, user.Login) - }() - liveshareLogger := noopLogger() if opts.debug { debugLogger, err := newFileLogger(opts.debugFile) @@ -85,14 +142,13 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e session, err := codespaces.ConnectToLiveshare(ctx, a, liveshareLogger, a.apiClient, codespace) if err != nil { - return fmt.Errorf("error connecting to Live Share: %w", err) + if authErr := <-authkeys; authErr != nil { + return authErr + } + return fmt.Errorf("error connecting to codespace: %w", err) } defer safeClose(session, &err) - if err := <-authkeys; err != nil { - return err - } - a.StartProgressIndicatorWithLabel("Fetching SSH Details") remoteSSHServerPort, sshUser, err := session.StartSSHServer(ctx) a.StopProgressIndicator() @@ -100,6 +156,13 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e 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 @@ -146,6 +209,130 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e } } +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 @@ -156,36 +343,36 @@ func newCpCmd(app *App) *cobra.Command { var opts cpOptions cpCmd := &cobra.Command{ - Use: "cp [-e] [-r] srcs... dest", + Use: "cp [-e] [-r] ... ", Short: "Copy files between local and remote file systems", - Long: ` -The cp command copies files between the local and remote file systems. + Long: heredoc.Docf(` + The cp command copies files between the local and remote file systems. -As with the UNIX cp 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. + 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 -r (recursive) flag is required if any source 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. + 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 -e flag, -each such argument is treated in the manner of scp, as a Bash expression to -be evaluated on the remote machine, subject to expansion of tildes, braces, -globs, environment variables, and backticks, as in these examples: - - $ gh codespace cp -e README.md 'remote:/workspace/$RepositoryName/' - $ gh codespace cp -e 'remote:~/*.go' ./gofiles/ - $ gh codespace cp -e 'remote:/workspace/myproj/go.{mod,sum}' ./gofiles/ - -For security, do not use the -e flag with arguments provided by untrusted -users; see https://lwn.net/Articles/835962/ for discussion. -`, + 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. @@ -276,3 +463,21 @@ func (fl *fileLogger) Name() string { 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/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 bc3f0dab4..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" ) @@ -106,7 +108,15 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { return err } - return m.Install(repo) + if err := m.Install(repo); err != nil { + return err + } + + if io.IsStdoutTTY() { + cs := io.ColorScheme() + fmt.Fprintf(io.Out, "%s Installed extension %s\n", cs.SuccessIcon(), args[0]) + } + return nil }, }, func() *cobra.Command { @@ -117,7 +127,7 @@ 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.FlagErrorf("must specify an extension to upgrade") + return cmdutil.FlagErrorf("specify an extension to upgrade or `--all`") } if len(args) > 0 && flagAll { return cmdutil.FlagErrorf("cannot use `--all` with extension name") @@ -134,16 +144,18 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { } cs := io.ColorScheme() err := m.Upgrade(name, flagForce) - if err != nil { + if err != nil && !errors.Is(err, upToDateError) { if name != "" { - fmt.Fprintf(io.ErrOut, "%s Failed upgrading extension %s: %s", cs.FailureIcon(), name, err) + fmt.Fprintf(io.ErrOut, "%s Failed upgrading extension %s: %s\n", cs.FailureIcon(), name, err) } else { - fmt.Fprintf(io.ErrOut, "%s Failed upgrading extensions", cs.FailureIcon()) + fmt.Fprintf(io.ErrOut, "%s Failed upgrading extensions\n", cs.FailureIcon()) } return cmdutil.SilentError } if io.IsStdoutTTY() { - if name != "" { + 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()) @@ -172,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 839484c3e..8f896eab0 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -1,6 +1,7 @@ package extension import ( + "errors" "io/ioutil" "net/http" "os" @@ -14,6 +15,7 @@ import ( "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" ) @@ -28,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 @@ -84,10 +87,10 @@ 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", @@ -120,6 +123,42 @@ func TestNewCmdExtension(t *testing.T) { }, 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"}, @@ -263,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) { @@ -281,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 @@ -295,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) { @@ -321,6 +458,11 @@ 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: ®} 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/manager.go b/pkg/cmd/extension/manager.go index f06594d05..3449092cc 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -2,6 +2,7 @@ package extension import ( "bytes" + _ "embed" "errors" "fmt" "io" @@ -16,8 +17,8 @@ import ( "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" @@ -32,7 +33,7 @@ type Manager struct { lookPath func(string) (string, error) findSh func() (string, error) newCommand func(string, ...string) *exec.Cmd - platform func() string + platform func() (string, string) client *http.Client config config.Config io *iostreams.IOStreams @@ -44,8 +45,12 @@ func NewManager(io *iostreams.IOStreams) *Manager { lookPath: safeexec.LookPath, findSh: findsh.Find, newCommand: exec.Command, - platform: func() string { - return fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH) + platform: func() (string, string) { + ext := "" + if runtime.GOOS == "windows" { + ext = ".exe" + } + return fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH), ext }, io: io, } @@ -268,7 +273,7 @@ func (m *Manager) populateLatestVersions(exts []Extension) { func (m *Manager) getLatestVersion(ext Extension) (string, error) { if ext.isLocal { - return "", fmt.Errorf("unable to get latest version for local extensions") + return "", localExtensionUpgradeError } if ext.IsBinary() { repo, err := ghrepo.FromFullName(ext.url) @@ -329,11 +334,10 @@ func (m *Manager) Install(repo ghrepo.Interface) error { return err } if !hs { - // TODO open an issue hint, here? - return errors.New("extension is uninstallable: missing executable") + return errors.New("extension is not installable: missing executable") } - protocol, _ := m.config.Get(repo.RepoHost(), "git_protocol") + protocol, _ := m.config.GetOrDefault(repo.RepoHost(), "git_protocol") return m.installGit(ghrepo.FormatRemoteURL(repo, protocol), m.io.Out, m.io.ErrOut) } @@ -344,19 +348,19 @@ func (m *Manager) installBin(repo ghrepo.Interface) error { return err } - suffix := m.platform() + platform, ext := m.platform() var asset *releaseAsset for _, a := range r.Assets { - if strings.HasSuffix(a.Name, suffix) { + if strings.HasSuffix(a.Name, platform+ext) { asset = &a break } } if asset == nil { - return fmt.Errorf("%s unsupported for %s. Open an issue: `gh issue create -R %s/%s -t'Support %s'`", - repo.RepoName(), - suffix, repo.RepoOwner(), repo.RepoName(), suffix) + 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() @@ -368,6 +372,7 @@ func (m *Manager) installBin(repo ghrepo.Interface) error { } binPath := filepath.Join(targetDir, name) + binPath += ext err = downloadAsset(m.client, *asset, binPath) if err != nil { @@ -482,6 +487,19 @@ func (m *Manager) upgradeExtension(ext Extension, force bool) 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 @@ -492,17 +510,14 @@ func (m *Manager) upgradeGitExtension(ext Extension, force bool) error { if err != nil { return err } - var cmds []*exec.Cmd dir := filepath.Dir(ext.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 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 runCmds(cmds) + return m.newCommand(exe, "-C", dir, "pull", "--ff-only").Run() } func (m *Manager) upgradeBinExtension(ext Extension) error { @@ -525,89 +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) error { - for _, cmd := range cmds { - 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) @@ -635,7 +680,11 @@ func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err for _, a := range r.Assets { dists := possibleDists() for _, d := range dists { - if strings.HasSuffix(a.Name, d) { + suffix := d + if strings.HasPrefix(d, "windows") { + suffix += ".exe" + } + if strings.HasSuffix(a.Name, suffix) { isBin = true break } @@ -645,6 +694,32 @@ func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err 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", diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index e46a30ef5..47f11d348 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -9,14 +9,19 @@ import ( "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" ) @@ -25,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 { @@ -52,8 +66,8 @@ func newTestManager(dir string, client *http.Client, io *iostreams.IOStreams) *M config: config.NewBlankConfig(), io: io, client: client, - platform: func() string { - return "windows-amd64" + platform: func() (string, string) { + return "windows-amd64", ".exe" }, } } @@ -217,16 +231,14 @@ func TestManager_UpgradeExtensions(t *testing.T) { assert.NoError(t, err) assert.Equal(t, heredoc.Docf( ` - [hello]: [git -C %s --git-dir=%s pull --ff-only] + [hello]: [git -C %s pull --ff-only] upgrade complete [local]: local extensions can not be upgraded - [two]: [git -C %s --git-dir=%s pull --ff-only] + [two]: [git -C %s pull --ff-only] upgrade complete `, 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()) } @@ -261,10 +273,9 @@ func TestManager_UpgradeExtension_GitExtension(t *testing.T) { assert.NoError(t, err) assert.Equal(t, heredoc.Docf( ` - [git -C %s --git-dir=%s pull --ff-only] + [git -C %s pull --ff-only] `, filepath.Join(tempDir, "extensions", "gh-remote"), - filepath.Join(tempDir, "extensions", "gh-remote", ".git"), ), stdout.String()) assert.Equal(t, "", stderr.String()) } @@ -272,7 +283,6 @@ func TestManager_UpgradeExtension_GitExtension(t *testing.T) { func TestManager_UpgradeExtension_GitExtension_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"))) io, _, stdout, stderr := iostreams.Test() m := newTestManager(tempDir, nil, io) @@ -286,23 +296,97 @@ func TestManager_UpgradeExtension_GitExtension_Force(t *testing.T) { 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] + [git -C %[1]s fetch origin HEAD] + [git -C %[1]s reset --hard origin/HEAD] `, extensionDir, - gitDir, - extensionDir, - gitDir, ), stdout.String()) assert.Equal(t, "", stderr.String()) } -func TestManager_UpgradeExtension_BinaryExtension(t *testing.T) { +func TestManager_MigrateToBinaryExtension(t *testing.T) { tempDir := t.TempDir() - io, _, _, _ := iostreams.Test() + 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{ @@ -311,7 +395,9 @@ func TestManager_UpgradeExtension_BinaryExtension(t *testing.T) { Host: "example.com", Tag: "v1.0.1", })) - m := newTestManager(tempDir, &client, io) + + 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( @@ -319,7 +405,7 @@ func TestManager_UpgradeExtension_BinaryExtension(t *testing.T) { Tag: "v1.0.2", Assets: []releaseAsset{ { - Name: "gh-bin-ext-windows-amd64", + Name: "gh-bin-ext-windows-amd64.exe", APIURL: "https://example.com/release/cool2", }, }, @@ -348,13 +434,15 @@ func TestManager_UpgradeExtension_BinaryExtension(t *testing.T) { Owner: "owner", Host: "example.com", Tag: "v1.0.2", - Path: filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext"), + 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")) + 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) { @@ -365,7 +453,6 @@ func TestManager_Install_git(t *testing.T) { client := http.Client{Transport: ®} io, _, stdout, stderr := iostreams.Test() - m := newTestManager(tempDir, &client, io) reg.Register( @@ -422,17 +509,16 @@ func TestManager_Install_binary_unsupported(t *testing.T) { }, })) - io, _, _, _ := iostreams.Test() + io, _, stdout, stderr := iostreams.Test() tempDir := t.TempDir() m := newTestManager(tempDir, &client, io) err := m.Install(repo) - assert.Error(t, err) + 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'`") - errText := "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, errText, err.Error()) + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) } func TestManager_Install_binary(t *testing.T) { @@ -440,7 +526,6 @@ func TestManager_Install_binary(t *testing.T) { 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"), @@ -448,7 +533,7 @@ func TestManager_Install_binary(t *testing.T) { release{ Assets: []releaseAsset{ { - Name: "gh-bin-ext-windows-amd64", + Name: "gh-bin-ext-windows-amd64.exe", APIURL: "https://example.com/release/cool", }, }, @@ -460,7 +545,7 @@ func TestManager_Install_binary(t *testing.T) { Tag: "v1.0.1", Assets: []releaseAsset{ { - Name: "gh-bin-ext-windows-amd64", + Name: "gh-bin-ext-windows-amd64.exe", APIURL: "https://example.com/release/cool", }, }, @@ -469,10 +554,10 @@ func TestManager_Install_binary(t *testing.T) { httpmock.REST("GET", "release/cool"), httpmock.StringResponse("FAKE BINARY")) - io, _, _, _ := iostreams.Test() + io, _, stdout, stderr := iostreams.Test() tempDir := t.TempDir() - m := newTestManager(tempDir, &client, io) + m := newTestManager(tempDir, &http.Client{Transport: ®}, io) err := m.Install(repo) assert.NoError(t, err) @@ -489,33 +574,121 @@ func TestManager_Install_binary(t *testing.T) { Owner: "owner", Host: "example.com", Tag: "v1.0.1", - Path: filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext"), + 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")) + 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) { - tempDir := t.TempDir() + 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, nil, nil) - 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 { 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 08d93c2be..b89d28e21 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -5,7 +5,6 @@ import ( "fmt" "net/http" "os" - "path/filepath" "time" "github.com/cli/cli/v2/api" @@ -19,17 +18,10 @@ import ( ) func New(appVersion string) *cmdutil.Factory { - var exe string f := &cmdutil.Factory{ - Config: configFunc(), // No factory dependencies - Branch: branchFunc(), // No factory dependencies - Executable: func() string { - if exe != "" { - return exe - } - exe = executable("gh") - return exe - }, + Config: configFunc(), // No factory dependencies + Branch: branchFunc(), // No factory dependencies + ExecutableName: "gh", } f.IOStreams = ioStreams(f) // Depends on Config @@ -121,52 +113,6 @@ func browserLauncher(f *cmdutil.Factory) string { return os.Getenv("BROWSER") } -// Finds the location of the executable for the current process as it's found in PATH, respecting symlinks. -// If the process couldn't determine its location, return fallbackName. If the executable wasn't found in -// PATH, return the absolute location to the program. -// -// The idea is that the result of this function is callable in the future and refers to the same -// installation of gh, even across upgrades. This is needed primarily for Homebrew, which installs software -// under a location such as `/usr/local/Cellar/gh/1.13.1/bin/gh` and symlinks it from `/usr/local/bin/gh`. -// When the version is upgraded, Homebrew will often delete older versions, but keep the symlink. Because of -// this, we want to refer to the `gh` binary as `/usr/local/bin/gh` and not as its internal Homebrew -// location. -// -// None of this would be needed if we could just refer to GitHub CLI as `gh`, i.e. without using an absolute -// path. However, for some reason Homebrew does not include `/usr/local/bin` in PATH when it invokes git -// commands to update its taps. If `gh` (no path) is being used as git credential helper, as set up by `gh -// auth login`, running `brew update` will print out authentication errors as git is unable to locate -// Homebrew-installed `gh`. -func executable(fallbackName string) string { - exe, err := os.Executable() - if err != nil { - return fallbackName - } - - base := filepath.Base(exe) - path := os.Getenv("PATH") - for _, dir := range filepath.SplitList(path) { - p, err := filepath.Abs(filepath.Join(dir, base)) - if err != nil { - continue - } - f, err := os.Stat(p) - if err != nil { - continue - } - - if p == exe { - return p - } else if f.Mode()&os.ModeSymlink != 0 { - if t, err := os.Readlink(p); err == nil && t == exe { - return p - } - } - } - - return exe -} - func configFunc() func() (config.Config, error) { var cachedConfig config.Config var configError error @@ -220,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: