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/releases.yml b/.github/workflows/releases.yml index f44689804..6511fc71c 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -25,6 +25,15 @@ jobs: -q .body > CHANGELOG.md env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Install osslsigncode + run: sudo apt-get install -y osslsigncode + - name: Obtain signing cert + run: | + cert="$(mktemp -t cert.XXX)" + base64 -d <<<"$CERT_CONTENTS" > "$cert" + echo "CERT_FILE=$cert" >> $GITHUB_ENV + env: + CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }} - name: Run GoReleaser uses: goreleaser/goreleaser-action@v2 with: @@ -33,6 +42,7 @@ jobs: env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} GORELEASER_CURRENT_TAG: ${{steps.changelog.outputs.tag-name}} + CERT_PASSWORD: ${{secrets.WINDOWS_CERT_PASSWORD}} - name: Checkout documentation site uses: actions/checkout@v2 with: @@ -61,7 +71,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,34 +138,33 @@ 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 + shell: bash + run: | + base64 -d <<<"$CERT_CONTENTS" > ./cert.pfx + printf "::set-output name=cert-file::%s\n" ".\\cert.pfx" env: - DESKTOP_CERT_TOKEN: ${{ secrets.DESKTOP_CERT_TOKEN }} - run: .\script\setup-windows-certificate.ps1 + CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }} - name: Sign MSI env: CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }} EXE_FILE: ${{ steps.buildmsi.outputs.msi }} - GITHUB_CERT_PASSWORD: ${{ secrets.GITHUB_CERT_PASSWORD }} - run: .\script\sign.ps1 -Certificate $env:CERT_FILE -Executable $env:EXE_FILE + CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }} + run: .\script\signtool sign /d "GitHub CLI" /f $env:CERT_FILE /p $env:CERT_PASSWORD /fd sha256 /tr http://timestamp.digicert.com /v $env:EXE_FILE - name: Upload MSI shell: bash run: | diff --git a/.goreleaser.yml b/.goreleaser.yml index 95d43500d..68d3dd9c9 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -32,6 +32,9 @@ builds: id: windows goos: [windows] goarch: [386, amd64] + hooks: + post: + - ./script/sign-windows-executable.sh '{{ .Path }}' archives: - id: nix diff --git a/api/client.go b/api/client.go index 3fcf0d930..bfd526fbc 100644 --- a/api/client.go +++ b/api/client.go @@ -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) } @@ -220,7 +236,23 @@ func ScopesSuggestion(resp *http.Response) string { for _, s := range strings.Split(tokenHasScopes, ",") { s = strings.TrimSpace(s) gotScopes[s] = struct{}{} - if strings.HasPrefix(s, "admin:") { + + // Certain scopes may be grouped under a single "top-level" scope. The following branch + // statements include these grouped/implied scopes when the top-level scope is encountered. + // See https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps. + if s == "repo" { + gotScopes["repo:status"] = struct{}{} + gotScopes["repo_deployment"] = struct{}{} + gotScopes["public_repo"] = struct{}{} + gotScopes["repo:invite"] = struct{}{} + gotScopes["security_events"] = struct{}{} + } else if s == "user" { + gotScopes["read:user"] = struct{}{} + gotScopes["user:email"] = struct{}{} + gotScopes["user:follow"] = struct{}{} + } else if s == "codespace" { + gotScopes["codespace:secrets"] = struct{}{} + } else if strings.HasPrefix(s, "admin:") { gotScopes["read:"+strings.TrimPrefix(s, "admin:")] = struct{}{} gotScopes["write:"+strings.TrimPrefix(s, "admin:")] = struct{}{} } else if strings.HasPrefix(s, "write:") { @@ -310,6 +342,7 @@ func (c Client) REST(hostname string, method string, p string, body io.Reader, d if err != nil { return err } + err = json.Unmarshal(b, &data) if err != nil { return err 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_pr.go b/api/queries_pr.go index 300846c23..b89e20609 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 } } @@ -79,18 +80,7 @@ type PullRequest struct { Commit struct { StatusCheckRollup struct { Contexts struct { - Nodes []struct { - TypeName string `json:"__typename"` - Name string `json:"name"` - Context string `json:"context,omitempty"` - State string `json:"state,omitempty"` - Status string `json:"status"` - Conclusion string `json:"conclusion"` - StartedAt time.Time `json:"startedAt"` - CompletedAt time.Time `json:"completedAt"` - DetailsURL string `json:"detailsUrl"` - TargetURL string `json:"targetUrl,omitempty"` - } + Nodes []CheckContext PageInfo struct { HasNextPage bool EndCursor string @@ -108,9 +98,23 @@ type PullRequest struct { Comments Comments ReactionGroups ReactionGroups Reviews PullRequestReviews + LatestReviews PullRequestReviews ReviewRequests ReviewRequests } +type CheckContext struct { + TypeName string `json:"__typename"` + Name string `json:"name"` + Context string `json:"context,omitempty"` + State string `json:"state,omitempty"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + StartedAt time.Time `json:"startedAt"` + CompletedAt time.Time `json:"completedAt"` + DetailsURL string `json:"detailsUrl"` + TargetURL string `json:"targetUrl,omitempty"` +} + type PRRepository struct { ID string `json:"id"` Name string `json:"name"` @@ -405,6 +409,11 @@ func PullRequestStatus(client *Client, repo ghrepo.Interface, options StatusOpti } pullRequest(number: $number) { ...prWithReviews + baseRef { + branchProtectionRule { + requiredApprovingReviewCount + } + } } } ` @@ -519,7 +528,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(` diff --git a/api/queries_repo.go b/api/queries_repo.go index 9fbc26e31..b7ea14915 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -11,6 +11,8 @@ import ( "strings" "time" + "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/internal/ghrepo" "github.com/shurcooL/githubv4" ) @@ -524,6 +526,37 @@ func ForkRepo(client *Client, repo ghrepo.Interface, org string) (*Repository, e }, nil } +// RenameRepo renames the repository on GitHub and returns the renamed repository +func RenameRepo(client *Client, repo ghrepo.Interface, newRepoName string) (*Repository, error) { + input := map[string]string{"name": newRepoName} + body := &bytes.Buffer{} + enc := json.NewEncoder(body) + if err := enc.Encode(input); err != nil { + return nil, err + } + + path := fmt.Sprintf("%srepos/%s", + ghinstance.RESTPrefix(repo.RepoHost()), + ghrepo.FullName(repo)) + + result := repositoryV3{} + err := client.REST(repo.RepoHost(), "PATCH", path, body, &result) + if err != nil { + return nil, err + } + + return &Repository{ + ID: result.NodeID, + Name: result.Name, + CreatedAt: result.CreatedAt, + Owner: RepositoryOwner{ + Login: result.Owner.Login, + }, + ViewerPermission: "WRITE", + hostname: repo.RepoHost(), + }, nil +} + func LastCommit(client *Client, repo ghrepo.Interface) (*Commit, error) { var responseData struct { Repository struct { @@ -983,6 +1016,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 @@ -1128,46 +1170,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 4b6e2837d..5091e656f 100644 --- a/api/queries_repo_test.go +++ b/api/queries_repo_test.go @@ -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 88f8bfa4b..977089df6 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -82,6 +82,18 @@ var prReviews = shortenQuery(` } `) +var prLatestReviews = shortenQuery(` + latestReviews(first: 100) { + nodes { + author{login}, + authorAssociation, + submittedAt, + body, + state + } + } +`) + var prFiles = shortenQuery(` files(first: 100) { nodes { @@ -180,6 +192,7 @@ var PullRequestFields = append(IssueFields, "headRepositoryOwner", "isCrossRepository", "isDraft", + "latestReviews", "maintainerCanModify", "mergeable", "mergeCommit", @@ -229,6 +242,8 @@ func PullRequestGraphQL(fields []string) string { 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/gh/main.go b/cmd/gh/main.go index 50f8335a3..a8f1ed141 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -24,6 +24,7 @@ import ( "github.com/cli/cli/v2/pkg/cmd/factory" "github.com/cli/cli/v2/pkg/cmd/root" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" "github.com/cli/cli/v2/utils" "github.com/cli/safeexec" "github.com/mattn/go-colorable" @@ -145,7 +146,7 @@ func mainRun() exitCode { if errors.As(err, &execError) { return exitCode(execError.ExitCode()) } - fmt.Fprintf(stderr, "failed to run external command: %s", err) + fmt.Fprintf(stderr, "failed to run external command: %s\n", err) return exitError } @@ -157,7 +158,7 @@ func mainRun() exitCode { if errors.As(err, &execError) { return exitCode(execError.ExitCode()) } - fmt.Fprintf(stderr, "failed to run extension: %s", err) + fmt.Fprintf(stderr, "failed to run extension: %s\n", err) return exitError } else if found { return exitOK @@ -201,6 +202,7 @@ func mainRun() exitCode { rootCmd.SetArgs(expandedArgs) if cmd, err := rootCmd.ExecuteC(); err != nil { + var pagerPipeError *iostreams.ErrClosedPagerPipe if err == cmdutil.SilentError { return exitError } else if cmdutil.IsUserCancellation(err) { @@ -211,6 +213,9 @@ func mainRun() exitCode { return exitCancel } else if errors.Is(err, authError) { return exitAuth + } else if errors.As(err, &pagerPipeError) { + // ignore the error raised when piping to a closed pager + return exitOK } printError(stderr, err, cmd, hasDebug) @@ -224,8 +229,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 b11c2faa9..2c3579d65 100644 --- a/docs/install_linux.md +++ b/docs/install_linux.md @@ -29,13 +29,19 @@ sudo apt install gh ### Fedora, CentOS, Red Hat Enterprise Linux (dnf) -Install: +Install from our package repository for immediate access to latest releases: ```bash sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo sudo dnf install gh ``` +Alternatively, install from the [community repository](https://packages.fedoraproject.org/pkgs/gh/gh/): + +```bash +sudo dnf install gh +``` + Upgrade: ```bash @@ -179,6 +185,30 @@ openSUSE Tumbleweed users can install from the [official distribution repo](http sudo zypper in gh ``` +### Alpine Linux + +Alpine Linux users can install from the [stable releases' community packaage repository](https://pkgs.alpinelinux.org/packages?name=github-cli&branch=v3.15). + +```bash +apk add github-cli +``` + +Users wanting the latest version of the CLI without waiting to be backported into the stable release they're using should use the edge release's +community repo through this method below, without mixing packages from stable and unstable repos.[^1] + +```bash +echo "@community http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories +apk add github-cli@community +``` + +### Void Linux +Void Linux users can install from the [official distribution repo](https://voidlinux.org/packages/?arch=x86_64&q=github-cli): + +```bash +sudo xbps-install github-cli +``` + [releases page]: https://github.com/cli/cli/releases/latest [arch linux repo]: https://www.archlinux.org/packages/community/x86_64/github-cli [arch linux aur]: https://aur.archlinux.org/packages/github-cli-git +[^1]: https://wiki.alpinelinux.org/wiki/Package_management#Repository_pinning diff --git a/docs/triage.md b/docs/triage.md index caad1e52c..8a329de7c 100644 --- a/docs/triage.md +++ b/docs/triage.md @@ -6,31 +6,23 @@ triage role. The initial expectation is that the person in the role for the week ## Expectations for incoming issues -All incoming issues need either an **enhancement**, **bug**, or **docs** label. +All incoming issues need either an `enhancement`, `bug`, or `docs` label. -To be considered triaged, **enhancement** issues require at least one of the following additional labels: +To be considered triaged, `enhancement` issues require at least one of the following additional labels: -- **core**: work reserved for the core CLI team -- **help wanted**: work that we would accept contributions for -- **needs-design**: work that requires input from a UX designer before it can move forward -- **needs-investigation**: work that requires a mystery be solved by the core team before it can move forward -- **needs-user-input**: work that requires more information from the reporter before it can move forward +- `core`: reserved for the core CLI team +- `help wanted`: signal that we are accepting contributions for this +- `discuss`: add to our team's queue to discuss during a sync +- `needs-investigation`: work that requires a mystery be solved by the core team before it can move forward +- `needs-user-input`: we need more information from our users before the task can move forward -To be considered triaged, **bug** issues require a severity label: one of **p1**, **p2**, or **p3** - -For a more detailed breakdown of **how** to triage an issue, see the _Issue triage flowchart_ below. +To be considered triaged, `bug` issues require a severity label: one of `p1`, `p2`, or `p3` ## Expectations for community pull requests -To be considered triaged, incoming pull requests should: - -- be checked for a corresponding **help wanted** issue -- be checked for basic quality: are the builds passing? have tests been added? -- be checked for redundancy: is there already a PR dealing with this? - -Once a pull request has been triaged, it should be moved to the **Needs Review** column of the project board. - -For a more detailed breakdown of **how** to triage an issue, see the _PR triage flowchart_ below. +All incoming pull requests are assigned to one of the engineers for review on a round-robin basis. +The person in a triage role for a week could take a glance at these pull requests, mostly to see whether +the changeset is feasible and to allow the associated CI run for new contributors. ## Issue triage flowchart @@ -46,30 +38,17 @@ For a more detailed breakdown of **how** to triage an issue, see the _PR triage - add `help wanted` label - consider adding `good first issue` label - do we want to do it? - - comment acknowledging it + - comment acknowledging that - add `core` label - - add to project TODO column if this is something that should ship soon + - add to the project “TODO” column if this is something that should ship soon - is it intriguing, but requires discussion? - - label `needs-design` if design input is needed, ping + - label `discuss` - label `needs-investigation` if engineering research is required before action can be taken - does it need more info from the issue author? - ask the user for details - add `needs-user-input` label - is it a usage/support question? - - offer some instructions/workaround and close - -## Pull request triage flowchart - -- can it be closed outright? - - e.g. spam/junk - - close -- do we not want to do it? - - comment and close -- is it intriguing, but requires discussion and there is no referenced issue? - - request an issue - - close -- is it something we want to include? - - add to `needs review` column + - consider converting the Issue to a Discussion ## Weekly PR audit diff --git a/go.mod b/go.mod index ca455277d..59cd5006d 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.16 require ( github.com/AlecAivazis/survey/v2 v2.3.2 github.com/MakeNowJust/heredoc v1.0.0 - github.com/briandowns/spinner v1.18.0 - github.com/charmbracelet/glamour v0.3.0 + github.com/briandowns/spinner v1.18.1 + 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 @@ -14,20 +14,19 @@ require ( 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.6 + github.com/itchyny/gojq v0.12.7 github.com/joho/godotenv v1.4.0 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 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 @@ -39,7 +38,7 @@ require ( github.com/stretchr/testify v1.7.0 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-20211205182925-97ca703d548d + golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 golang.org/x/term v0.0.0-20210503060354-a79de5458b56 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) diff --git a/go.sum b/go.sum index f2c325538..b91f9192a 100644 --- a/go.sum +++ b/go.sum @@ -56,15 +56,8 @@ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6 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/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= -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/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= @@ -81,15 +74,15 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 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/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/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY= +github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 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.3.0 h1:3H+ZrKlSg8s+WU6V7eF2eRVYt8lCueffbi7r2+ffGkc= -github.com/charmbracelet/glamour v0.3.0/go.mod h1:TzF0koPZhqq0YVBNL100cPHznAAjVj7fksX2RInwjGw= +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= @@ -123,13 +116,11 @@ github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKY 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= @@ -206,8 +197,9 @@ 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= @@ -285,8 +277,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1: 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/gojq v0.12.6 h1:VjaFn59Em2wTxDNGcrRkDK9ZHMNa8IksOgL13sLL4d0= -github.com/itchyny/gojq v0.12.6/go.mod h1:ZHrkfu7A+RbZLy5J1/JKpS4poEqrzItSTGDItqsfP0A= +github.com/itchyny/gojq v0.12.7 h1:hYPTpeWfrJ1OT+2j6cvBScbhl0TkdwGM4bc66onUSOQ= +github.com/itchyny/gojq v0.12.7/go.mod h1:ZdvNHVlzPgUf8pgjnuDTmGfHA/21KoutQUJ3An/xNuw= 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= @@ -333,16 +325,15 @@ github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky 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/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= @@ -357,10 +348,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ 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/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -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/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= @@ -375,7 +364,6 @@ github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144T 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= @@ -403,8 +391,6 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD 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/graphql v0.0.0-20200928012149-18c5c3165e3a h1:KikTa6HtAK8cS1qjvUvvq4QO21QnwC+EfvB+OAuZ/ZU= @@ -440,9 +426,9 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de 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.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= @@ -531,7 +517,6 @@ 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= @@ -601,7 +586,6 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w 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= @@ -635,8 +619,9 @@ golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBc 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/sys v0.0.0-20220227234510-4e6760a101f9 h1:nhht2DYV/Sn3qOayu8lM+cU1ii9sTLUeBQwQQfUHtrs= +golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/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= 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 8de16bbb8..14da851ae 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -32,6 +32,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "io/ioutil" "net/http" "net/url" @@ -57,6 +58,7 @@ type API struct { vscsAPI string githubAPI string githubServer string + retryBackoff time.Duration } type httpClient interface { @@ -79,6 +81,7 @@ func New(serverURL, apiURL, vscsURL string, httpClient httpClient) *API { vscsAPI: strings.TrimSuffix(vscsURL, "/"), githubAPI: strings.TrimSuffix(apiURL, "/"), githubServer: strings.TrimSuffix(serverURL, "/"), + retryBackoff: 100 * time.Millisecond, } } @@ -158,14 +161,15 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error // Codespace represents a codespace. type Codespace struct { - Name string `json:"name"` - CreatedAt string `json:"created_at"` - LastUsedAt string `json:"last_used_at"` - Owner User `json:"owner"` - Repository Repository `json:"repository"` - State string `json:"state"` - GitStatus CodespaceGitStatus `json:"git_status"` - Connection CodespaceConnection `json:"connection"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + DisplayName string `json:"display_name"` + LastUsedAt string `json:"last_used_at"` + Owner User `json:"owner"` + Repository Repository `json:"repository"` + State string `json:"state"` + GitStatus CodespaceGitStatus `json:"git_status"` + Connection CodespaceConnection `json:"connection"` } type CodespaceGitStatus struct { @@ -195,6 +199,7 @@ type CodespaceConnection struct { // CodespaceFields is the list of exportable fields for a codespace. var CodespaceFields = []string{ + "displayName", "name", "owner", "repository", @@ -301,24 +306,24 @@ func findNextPage(linkValue string) string { // If the codespace is not found, an error is returned. // If includeConnection is true, it will return the connection information for the codespace. func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeConnection bool) (*Codespace, error) { - req, err := http.NewRequest( - http.MethodGet, - a.githubAPI+"/user/codespaces/"+codespaceName, - nil, - ) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - - if includeConnection { - q := req.URL.Query() - q.Add("internal", "true") - q.Add("refresh", "true") - req.URL.RawQuery = q.Encode() - } - - a.setHeaders(req) - resp, err := a.do(ctx, req, "/user/codespaces/*") + resp, err := a.withRetry(func() (*http.Response, error) { + req, err := http.NewRequest( + http.MethodGet, + a.githubAPI+"/user/codespaces/"+codespaceName, + nil, + ) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + if includeConnection { + q := req.URL.Query() + q.Add("internal", "true") + q.Add("refresh", "true") + req.URL.RawQuery = q.Encode() + } + a.setHeaders(req) + return a.do(ctx, req, "/user/codespaces/*") + }) if err != nil { return nil, fmt.Errorf("error making request: %w", err) } @@ -344,17 +349,18 @@ func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeCon // StartCodespace starts a codespace for the user. // If the codespace is already running, the returned error from the API is ignored. func (a *API) StartCodespace(ctx context.Context, codespaceName string) error { - req, err := http.NewRequest( - http.MethodPost, - a.githubAPI+"/user/codespaces/"+codespaceName+"/start", - nil, - ) - if err != nil { - return fmt.Errorf("error creating request: %w", err) - } - - a.setHeaders(req) - resp, err := a.do(ctx, req, "/user/codespaces/*/start") + resp, err := a.withRetry(func() (*http.Response, error) { + req, err := http.NewRequest( + http.MethodPost, + a.githubAPI+"/user/codespaces/"+codespaceName+"/start", + nil, + ) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + a.setHeaders(req) + return a.do(ctx, req, "/user/codespaces/*/start") + }) if err != nil { return fmt.Errorf("error making request: %w", err) } @@ -474,6 +480,84 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc return response.Machines, nil } +// RepoSearchParameters are the optional parameters for searching for repositories. +type RepoSearchParameters struct { + // The maximum number of repos to return. At most 100 repos are returned even if this value is greater than 100. + MaxRepos int + // The sort order for returned repos. Possible values are 'stars', 'forks', 'help-wanted-issues', or 'updated'. If empty the API's default ordering is used. + Sort string +} + +// GetCodespaceRepoSuggestions searches for and returns repo names based on the provided search text. +func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, parameters RepoSearchParameters) ([]string, error) { + reqURL := fmt.Sprintf("%s/search/repositories", a.githubAPI) + req, err := http.NewRequest(http.MethodGet, reqURL, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + parts := strings.SplitN(partialSearch, "/", 2) + + var nameSearch string + if len(parts) == 2 { + user := parts[0] + repo := parts[1] + nameSearch = fmt.Sprintf("%s user:%s", repo, user) + } else { + /* + * This results in searching for the text within the owner or the name. It's possible to + * do an owner search and then look up some repos for those owners, but that adds a + * good amount of latency to the fetch which slows down showing the suggestions. + */ + nameSearch = partialSearch + } + + queryStr := fmt.Sprintf("%s in:name", nameSearch) + + q := req.URL.Query() + q.Add("q", queryStr) + + if len(parameters.Sort) > 0 { + q.Add("sort", parameters.Sort) + } + + if parameters.MaxRepos > 0 { + q.Add("per_page", strconv.Itoa(parameters.MaxRepos)) + } + + req.URL.RawQuery = q.Encode() + + a.setHeaders(req) + resp, err := a.do(ctx, req, "/search/repositories/*") + if err != nil { + return nil, fmt.Errorf("error searching repositories: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, api.HandleHTTPError(resp) + } + + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + + var response struct { + Items []*Repository `json:"items"` + } + if err := json.Unmarshal(b, &response); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %w", err) + } + + repoNames := make([]string, len(response.Items)) + for i, repo := range response.Items { + repoNames[i] = repo.FullName + } + + return repoNames, nil +} + // CreateCodespaceParams are the required parameters for provisioning a Codespace. type CreateCodespaceParams struct { RepositoryID int @@ -481,6 +565,7 @@ type CreateCodespaceParams struct { Branch string Machine string Location string + PermissionsOptOut bool } // CreateCodespace creates a codespace with the given parameters and returns a non-nil error if it @@ -526,10 +611,20 @@ type startCreateRequest struct { Ref string `json:"ref"` Location string `json:"location"` Machine string `json:"machine"` + PermissionsOptOut bool `json:"devcontainer_permissions_opt_out"` } var errProvisioningInProgress = errors.New("provisioning in progress") +type AcceptPermissionsRequiredError struct { + Message string `json:"message"` + AllowPermissionsURL string `json:"allow_permissions_url"` +} + +func (e AcceptPermissionsRequiredError) Error() string { + return e.Message +} + // startCreate starts the creation of a codespace. // It may return success or an error, or errProvisioningInProgress indicating that the operation // did not complete before the GitHub API's time limit for RPCs (10s), in which case the caller @@ -545,6 +640,7 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (* Ref: params.Branch, Location: params.Location, Machine: params.Machine, + PermissionsOptOut: params.PermissionsOptOut, }) if err != nil { return nil, fmt.Errorf("error marshaling request: %w", err) @@ -564,6 +660,29 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (* if resp.StatusCode == http.StatusAccepted { return nil, errProvisioningInProgress // RPC finished before result of creation known + } else if resp.StatusCode == http.StatusUnauthorized { + var ( + ue AcceptPermissionsRequiredError + bodyCopy = &bytes.Buffer{} + r = io.TeeReader(resp.Body, bodyCopy) + ) + + b, err := ioutil.ReadAll(r) + if err != nil { + return nil, fmt.Errorf("error reading response body: %w", err) + } + if err := json.Unmarshal(b, &ue); err != nil { + return nil, fmt.Errorf("error unmarshaling response: %w", err) + } + + if ue.AllowPermissionsURL != "" { + return nil, ue + } + + resp.Body = ioutil.NopCloser(bodyCopy) + + return nil, api.HandleHTTPError(resp) + } else if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return nil, api.HandleHTTPError(resp) } @@ -686,3 +805,19 @@ func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http func (a *API) setHeaders(req *http.Request) { req.Header.Set("Accept", "application/vnd.github.v3+json") } + +// withRetry takes a generic function that sends an http request and retries +// only when the returned response has a >=500 status code. +func (a *API) withRetry(f func() (*http.Response, error)) (resp *http.Response, err error) { + for i := 0; i < 5; i++ { + resp, err = f() + if err != nil { + return nil, err + } + if resp.StatusCode < 500 { + break + } + time.Sleep(a.retryBackoff * (time.Duration(i) + 1)) + } + return resp, err +} diff --git a/internal/codespaces/api/api_test.go b/internal/codespaces/api/api_test.go index 6dcd06a04..af394cf7e 100644 --- a/internal/codespaces/api/api_test.go +++ b/internal/codespaces/api/api_test.go @@ -114,3 +114,154 @@ func TestListCodespaces_unlimited(t *testing.T) { t.Fatalf("expected codespace-249, got %s", codespaces[0].Name) } } + +func TestGetRepoSuggestions(t *testing.T) { + tests := []struct { + searchText string // The input search string + queryText string // The wanted query string (based off searchText) + sort string // (Optional) The RepoSearchParameters.Sort param + maxRepos string // (Optional) The RepoSearchParameters.MaxRepos param + }{ + { + searchText: "test", + queryText: "test", + }, + { + searchText: "org/repo", + queryText: "repo user:org", + }, + { + searchText: "org/repo/extra", + queryText: "repo/extra user:org", + }, + { + searchText: "test", + queryText: "test", + sort: "stars", + maxRepos: "1000", + }, + } + + for _, tt := range tests { + runRepoSearchTest(t, tt.searchText, tt.queryText, tt.sort, tt.maxRepos) + } +} + +func createFakeSearchReposServer(t *testing.T, wantSearchText string, wantSort string, wantPerPage string, responseRepos []*Repository) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/search/repositories" { + t.Error("Incorrect path") + return + } + + query := r.URL.Query() + got := fmt.Sprintf("q=%q sort=%s per_page=%s", query.Get("q"), query.Get("sort"), query.Get("per_page")) + want := fmt.Sprintf("q=%q sort=%s per_page=%s", wantSearchText+" in:name", wantSort, wantPerPage) + if got != want { + t.Errorf("for query, got %s, want %s", got, want) + return + } + + response := struct { + Items []*Repository `json:"items"` + }{ + responseRepos, + } + + if err := json.NewEncoder(w).Encode(response); err != nil { + t.Error(err) + } + })) +} + +func runRepoSearchTest(t *testing.T, searchText, wantQueryText, wantSort, wantMaxRepos string) { + wantRepoNames := []string{"repo1", "repo2"} + + apiResponseRepositories := make([]*Repository, 0) + for _, name := range wantRepoNames { + apiResponseRepositories = append(apiResponseRepositories, &Repository{FullName: name}) + } + + svr := createFakeSearchReposServer(t, wantQueryText, wantSort, wantMaxRepos, apiResponseRepositories) + defer svr.Close() + + api := API{ + githubAPI: svr.URL, + client: &http.Client{}, + } + + ctx := context.Background() + + searchParameters := RepoSearchParameters{} + if len(wantSort) > 0 { + searchParameters.Sort = wantSort + } + if len(wantMaxRepos) > 0 { + searchParameters.MaxRepos, _ = strconv.Atoi(wantMaxRepos) + } + + gotRepoNames, err := api.GetCodespaceRepoSuggestions(ctx, searchText, searchParameters) + if err != nil { + t.Fatal(err) + } + + gotNamesStr := fmt.Sprintf("%v", gotRepoNames) + wantNamesStr := fmt.Sprintf("%v", wantRepoNames) + if gotNamesStr != wantNamesStr { + t.Fatalf("got repo names %s, want %s", gotNamesStr, wantNamesStr) + } +} + +func TestRetries(t *testing.T) { + var callCount int + csName := "test_codespace" + handler := func(w http.ResponseWriter, r *http.Request) { + if callCount == 3 { + err := json.NewEncoder(w).Encode(Codespace{ + Name: csName, + }) + if err != nil { + t.Fatal(err) + } + return + } + callCount++ + w.WriteHeader(502) + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler(w, r) })) + t.Cleanup(srv.Close) + a := &API{ + githubAPI: srv.URL, + client: &http.Client{}, + } + cs, err := a.GetCodespace(context.Background(), "test", false) + if err != nil { + t.Fatal(err) + } + if callCount != 3 { + t.Fatalf("expected at least 2 retries but got %d", callCount) + } + if cs.Name != csName { + t.Fatalf("expected codespace name to be %q but got %q", csName, cs.Name) + } + callCount = 0 + handler = func(w http.ResponseWriter, r *http.Request) { + callCount++ + err := json.NewEncoder(w).Encode(Codespace{ + Name: csName, + }) + if err != nil { + t.Fatal(err) + } + } + cs, err = a.GetCodespace(context.Background(), "test", false) + if err != nil { + t.Fatal(err) + } + if callCount != 1 { + t.Fatalf("expected no retries but got %d calls", callCount) + } + if cs.Name != csName { + t.Fatalf("expected codespace name to be %q but got %q", csName, cs.Name) + } +} 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_type.go b/internal/config/config_type.go index 92792e93f..68da2af29 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) @@ -52,7 +55,7 @@ var configOptions = []ConfigOption{ }, { Key: "http_unix_socket", - Description: "the path to a unix socket through which to make HTTP connection", + Description: "the path to a Unix socket through which to make an HTTP connection", DefaultValue: "", }, { 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 ad31537f4..3cc19879d 100644 --- a/internal/config/from_env.go +++ b/internal/config/from_env.go @@ -3,9 +3,11 @@ package config import ( "fmt" "os" + "sort" "strconv" "github.com/cli/cli/v2/internal/ghinstance" + "github.com/cli/cli/v2/pkg/set" ) const ( @@ -34,19 +36,29 @@ type envConfig struct { } func (c *envConfig) Hosts() ([]string, error) { - hasDefault := false hosts, err := c.Config.Hosts() - for _, h := range hosts { - if h == ghinstance.Default() { - hasDefault = true - } + if err != nil { + return nil, err } - token, _ := AuthTokenFromEnv(ghinstance.Default()) - if (err != nil || !hasDefault) && token != "" { - hosts = append([]string{ghinstance.Default()}, hosts...) - return hosts, nil + + hostSet := set.NewStringSet() + hostSet.AddValues(hosts) + + // If GH_HOST is set then add it to list. + if host := os.Getenv(GH_HOST); host != "" { + hostSet.Add(host) } - return hosts, err + + // If there is a valid environment variable token for the + // default host then add default host to list. + if token, _ := AuthTokenFromEnv(ghinstance.Default()); token != "" { + hostSet.Add(ghinstance.Default()) + } + + s := hostSet.ToSlice() + // If default host is in list then move it to the front. + sort.SliceStable(s, func(i, j int) bool { return s[i] == ghinstance.Default() }) + return s, nil } func (c *envConfig) DefaultHost() (string, error) { @@ -76,6 +88,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 != "" { diff --git a/internal/config/from_env_test.go b/internal/config/from_env_test.go index bf81c7976..4bce09e85 100644 --- a/internal/config/from_env_test.go +++ b/internal/config/from_env_test.go @@ -44,6 +44,7 @@ func TestInheritEnv(t *testing.T) { tests := []struct { name string baseConfig string + GH_HOST string GITHUB_TOKEN string GITHUB_ENTERPRISE_TOKEN string GH_TOKEN string @@ -285,9 +286,23 @@ func TestInheritEnv(t *testing.T) { writeable: false, }, }, + { + name: "GH_HOST adds host entry when paired with environment token", + baseConfig: ``, + GH_HOST: "example.org", + GH_ENTERPRISE_TOKEN: "GH_ENTERPRISE_TOKEN", + hostname: "example.org", + wants: wants{ + hosts: []string{"example.org"}, + token: "GH_ENTERPRISE_TOKEN", + source: "GH_ENTERPRISE_TOKEN", + writeable: false, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + setenv(t, "GH_HOST", tt.GH_HOST) setenv(t, "GITHUB_TOKEN", tt.GITHUB_TOKEN) setenv(t, "GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN) setenv(t, "GH_TOKEN", tt.GH_TOKEN) 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/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/pkg/cmd/actions/actions.go b/pkg/cmd/actions/actions.go index e5620c42e..d24df6631 100644 --- a/pkg/cmd/actions/actions.go +++ b/pkg/cmd/actions/actions.go @@ -11,12 +11,10 @@ func NewCmdActions(f *cmdutil.Factory) *cobra.Command { cs := f.IOStreams.ColorScheme() cmd := &cobra.Command{ - Use: "actions", - Short: "Learn about working with GitHub Actions", - Long: actionsExplainer(cs), - Annotations: map[string]string{ - "IsActions": "true", - }, + Use: "actions", + Short: "Learn about working with GitHub Actions", + Long: actionsExplainer(cs), + Hidden: true, } cmdutil.DisableAuthCheck(cmd) diff --git a/pkg/cmd/alias/list/list.go b/pkg/cmd/alias/list/list.go index 85609e7a4..973a4ebee 100644 --- a/pkg/cmd/alias/list/list.go +++ b/pkg/cmd/alias/list/list.go @@ -24,8 +24,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman } cmd := &cobra.Command{ - Use: "list", - Short: "List your aliases", + Use: "list", + Short: "List your aliases", + Aliases: []string{"ls"}, Long: heredoc.Doc(` This command prints out all of the aliases gh is configured to use. `), diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 473139faf..36f8eeb23 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -3,7 +3,6 @@ package api import ( "bytes" "encoding/json" - "errors" "fmt" "io" "io/ioutil" @@ -13,7 +12,6 @@ import ( "sort" "strconv" "strings" - "syscall" "time" "github.com/MakeNowJust/heredoc" @@ -21,6 +19,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,10 +70,12 @@ 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. @@ -167,6 +168,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") @@ -210,7 +214,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a typed parameter in `key=value` format") cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format") cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add a HTTP request header in `key:value` format") - cmd.Flags().StringSliceVarP(&opts.Previews, "preview", "p", nil, "Opt into GitHub API previews") + cmd.Flags().StringSliceVarP(&opts.Previews, "preview", "p", nil, "GitHub API preview `names` to request (without the \"-preview\" suffix)") cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output") cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results") cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request (use \"-\" to read from standard input)") @@ -273,11 +277,11 @@ func apiRun(opts *ApiOptions) error { if opts.Silent { opts.IO.Out = ioutil.Discard } else { - err := opts.IO.StartPager() - if err != nil { - return err + if err := opts.IO.StartPager(); err == nil { + defer opts.IO.StopPager() + } else { + fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) } - defer opts.IO.StopPager() } cfg, err := opts.Config() @@ -360,13 +364,13 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream responseBody = io.TeeReader(responseBody, bodyCopy) } - if opts.FilterOutput != "" { + if opts.FilterOutput != "" && serverError == "" { // TODO: reuse parsed query across pagination invocations err = export.FilterJSON(opts.IO.Out, responseBody, opts.FilterOutput) if err != nil { return } - } else if opts.Template != "" { + } else if opts.Template != "" && serverError == "" { // TODO: reuse parsed template across pagination invocations err = template.Execute(responseBody) if err != nil { @@ -378,11 +382,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream _, err = io.Copy(opts.IO.Out, responseBody) } if err != nil { - if errors.Is(err, syscall.EPIPE) { - err = nil - } else { - return - } + return } if serverError == "" && resp.StatusCode > 299 { @@ -393,6 +393,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 } diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index 04da8cc29..25190ea5f 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -490,6 +490,20 @@ func Test_apiRun(t *testing.T) { stdout: "not a cat", stderr: ``, }, + { + name: "output template when REST error", + options: ApiOptions{ + Template: `{{.status}}`, + }, + httpResponse: &http.Response{ + StatusCode: 400, + Body: ioutil.NopCloser(bytes.NewBufferString(`{"message": "THIS IS FINE"}`)), + Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}}, + }, + err: cmdutil.SilentError, + stdout: `{"message": "THIS IS FINE"}`, + stderr: "gh: THIS IS FINE (HTTP 400)\n", + }, { name: "jq filter", options: ApiOptions{ @@ -504,6 +518,20 @@ func Test_apiRun(t *testing.T) { stdout: "Mona\nHubot\n", stderr: ``, }, + { + name: "jq filter when REST error", + options: ApiOptions{ + FilterOutput: `.[].name`, + }, + httpResponse: &http.Response{ + StatusCode: 400, + Body: ioutil.NopCloser(bytes.NewBufferString(`{"message": "THIS IS FINE"}`)), + Header: http.Header{"Content-Type": []string{"application/json; charset=utf-8"}}, + }, + err: cmdutil.SilentError, + stdout: `{"message": "THIS IS FINE"}`, + stderr: "gh: THIS IS FINE (HTTP 400)\n", + }, } for _, tt := range tests { diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go index e5ae67fa2..c3df8486d 100644 --- a/pkg/cmd/auth/auth.go +++ b/pkg/cmd/auth/auth.go @@ -14,8 +14,10 @@ import ( func NewCmdAuth(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "auth ", - Short: "Login, logout, and refresh your authentication", - Long: `Manage gh's authentication state.`, + Short: "Authenticate gh and git with GitHub", + Annotations: map[string]string{ + "IsCore": "true", + }, } cmdutil.DisableAuthCheck(cmd) diff --git a/pkg/cmd/auth/gitcredential/helper.go b/pkg/cmd/auth/gitcredential/helper.go index 8d1ab7ff3..bda962e50 100644 --- a/pkg/cmd/auth/gitcredential/helper.go +++ b/pkg/cmd/auth/gitcredential/helper.go @@ -100,12 +100,21 @@ 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 == "" { + gotUser = tokenUser + } } if gotUser == "" || gotToken == "" { diff --git a/pkg/cmd/auth/gitcredential/helper_test.go b/pkg/cmd/auth/gitcredential/helper_test.go index 7e30ec495..45488682d 100644 --- a/pkg/cmd/auth/gitcredential/helper_test.go +++ b/pkg/cmd/auth/gitcredential/helper_test.go @@ -8,6 +8,7 @@ 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) { @@ -74,6 +75,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{ @@ -138,6 +165,30 @@ func Test_helperRun(t *testing.T) { wantStdout: "", wantStderr: "", }, + { + name: "no username configured", + opts: CredentialOptions{ + Operation: "get", + Config: func() (config, error) { + return tinyConfig{ + "_source": "/Users/monalisa/.config/gh/hosts.yml", + "example.com:oauth_token": "OTOKEN", + }, nil + }, + }, + input: heredoc.Doc(` + protocol=https + host=example.com + `), + wantErr: false, + wantStdout: heredoc.Doc(` + protocol=https + host=example.com + username=x-access-token + password=OTOKEN + `), + wantStderr: "", + }, { name: "token from env", opts: CredentialOptions{ diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index f591fcbc6..e8353fd4a 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -27,10 +27,11 @@ type LoginOptions struct { Interactive bool - Hostname string - Scopes []string - Token string - Web bool + Hostname string + Scopes []string + Token string + Web bool + GitProtocol string } func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { @@ -49,13 +50,17 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm Long: heredoc.Docf(` Authenticate with a GitHub host. - The default authentication mode is a web-based browser flow. + The default authentication mode is a web-based browser flow. After completion, an + authentication token will be stored internally. - Alternatively, pass in a token on standard input by using %[1]s--with-token%[1]s. + Alternatively, use %[1]s--with-token%[1]s to pass in a token on standard input. The minimum required scopes for the token are: "repo", "read:org". - The --scopes flag accepts a comma separated list of scopes you want your gh credentials to have. If - absent, this command ensures that gh has access to a minimum set of scopes. + Alternatively, gh will use the authentication token found in environment variables. + This method is most suitable for "headless" use of gh such as in automation. See + %[1]sgh help environment%[1]s for more info. + + To use gh in GitHub Actions, add %[1]sGH_TOKEN: ${{secrets.GITHUB_TOKEN}}%[1]s to "env". `, "`"), Example: heredoc.Doc(` # start interactive setup @@ -64,41 +69,38 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm # authenticate against github.com by reading the token from a file $ gh auth login --with-token < mytoken.txt - # authenticate with a specific GitHub Enterprise Server instance + # authenticate with a specific GitHub instance $ 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() @@ -111,9 +113,10 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm } cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to authenticate with") - cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes for gh to have") + cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes to request") cmd.Flags().BoolVar(&tokenStdin, "with-token", false, "Read token from standard input") cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a browser to authenticate") + cmdutil.StringEnumFlag(cmd, &opts.GitProtocol, "git-protocol", "p", "", []string{"ssh", "https"}, "The protocol to use for git operations") return cmd } @@ -125,15 +128,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 } } @@ -193,6 +192,7 @@ func loginRun(opts *LoginOptions) error { Web: opts.Web, Scopes: opts.Scopes, Executable: opts.MainExecutable, + GitProtocol: opts.GitProtocol, }) } 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..1b7336f0b 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, @@ -46,7 +46,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. Use: "refresh", Args: cobra.ExactArgs(0), Short: "Refresh stored authentication credentials", - Long: heredoc.Doc(`Expand or fix the permission scopes for stored credentials + Long: heredoc.Doc(`Expand or fix the permission scopes for stored credentials. The --scopes flag accepts a comma separated list of scopes you want your gh credentials to have. If absent, this command ensures that gh has access to a minimum set of scopes. @@ -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/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go index 9e47bcc7e..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", "--replace-all", 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 dfdb72db9..fe674e1d7 100644 --- a/pkg/cmd/auth/shared/git_credential_test.go +++ b/pkg/cmd/auth/shared/git_credential_test.go @@ -22,7 +22,54 @@ 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 --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) { diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 0bac49b35..c33051e44 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -29,6 +29,7 @@ type LoginOptions struct { Web bool Scopes []string Executable string + GitProtocol string sshContext sshContext } @@ -39,8 +40,8 @@ func Login(opts *LoginOptions) error { httpClient := opts.HTTPClient cs := opts.IO.ColorScheme() - var gitProtocol string - if opts.Interactive { + gitProtocol := strings.ToLower(opts.GitProtocol) + if opts.Interactive && gitProtocol == "" { var proto string err := prompt.SurveyAskOne(&survey.Select{ Message: "What is your preferred protocol for Git operations?", @@ -99,7 +100,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 +118,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/codespace/code.go b/pkg/cmd/codespace/code.go index 2c66b4655..f80e32527 100644 --- a/pkg/cmd/codespace/code.go +++ b/pkg/cmd/codespace/code.go @@ -3,17 +3,11 @@ package codespace import ( "context" "fmt" - "io/ioutil" "net/url" - "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" ) -type browser interface { - Browse(string) error -} - func newCodeCmd(app *App) *cobra.Command { var ( codespace string @@ -25,8 +19,7 @@ func newCodeCmd(app *App) *cobra.Command { Short: "Open a codespace in Visual Studio Code", Args: noArgsConstraint, RunE: func(cmd *cobra.Command, args []string) error { - b := cmdutil.NewBrowser("", ioutil.Discard, app.io.ErrOut) - return app.VSCode(cmd.Context(), b, codespace, useInsiders) + return app.VSCode(cmd.Context(), codespace, useInsiders) }, } @@ -37,7 +30,7 @@ func newCodeCmd(app *App) *cobra.Command { } // VSCode opens a codespace in the local VS VSCode application. -func (a *App) VSCode(ctx context.Context, browser browser, codespaceName string, useInsiders bool) error { +func (a *App) VSCode(ctx context.Context, codespaceName string, useInsiders bool) error { if codespaceName == "" { codespace, err := chooseCodespace(ctx, a.apiClient) if err != nil { @@ -50,7 +43,7 @@ func (a *App) VSCode(ctx context.Context, browser browser, codespaceName string, } url := vscodeProtocolURL(codespaceName, useInsiders) - if err := browser.Browse(url); err != nil { + if err := a.browser.Browse(url); err != nil { return fmt.Errorf("error opening Visual Studio Code: %w", err) } diff --git a/pkg/cmd/codespace/code_test.go b/pkg/cmd/codespace/code_test.go index 956219bb2..cbc59864a 100644 --- a/pkg/cmd/codespace/code_test.go +++ b/pkg/cmd/codespace/code_test.go @@ -40,8 +40,10 @@ func TestApp_VSCode(t *testing.T) { 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 { + a := &App{ + browser: b, + } + if err := a.VSCode(context.Background(), 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..d21f9ff6c 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -21,19 +21,31 @@ import ( "golang.org/x/term" ) -type App struct { - io *iostreams.IOStreams - apiClient apiClient - errLogger *log.Logger +type browser interface { + Browse(string) error } -func NewApp(io *iostreams.IOStreams, apiClient apiClient) *App { +type executable interface { + Executable() string +} + +type App struct { + io *iostreams.IOStreams + apiClient apiClient + errLogger *log.Logger + executable executable + browser browser +} + +func NewApp(io *iostreams.IOStreams, exe executable, apiClient apiClient, browser browser) *App { errLogger := log.New(io.ErrOut, "", 0) return &App{ - io: io, - apiClient: apiClient, - errLogger: errLogger, + io: io, + apiClient: apiClient, + errLogger: errLogger, + executable: exe, + browser: browser, } } @@ -61,6 +73,7 @@ type apiClient interface { GetCodespaceRegionLocation(ctx context.Context) (string, error) GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) + GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) } var errNoCodespaces = errors.New("you have no codespaces") @@ -209,8 +222,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) } @@ -238,7 +256,7 @@ type codespace struct { } // displayName returns the repository nwo and branch. -// If includeName is true, the name of the codespace is included. +// If includeName is true, the name of the codespace (including displayName) is included. // If includeGitStatus is true, the branch will include a star if // the codespace has unsaved changes. func (c codespace) displayName(includeName, includeGitStatus bool) string { @@ -248,11 +266,18 @@ func (c codespace) displayName(includeName, includeGitStatus bool) string { } if includeName { + var displayName = c.Name + if c.DisplayName != "" { + displayName = c.DisplayName + } return fmt.Sprintf( - "%s: %s [%s]", c.Repository.FullName, branch, c.Name, + "%s: %s (%s)", c.Repository.FullName, displayName, branch, ) } - return c.Repository.FullName + ": " + branch + return fmt.Sprintf( + "%s: %s", c.Repository.FullName, branch, + ) + } // gitStatusDirty represents an unsaved changes status. diff --git a/pkg/cmd/codespace/common_test.go b/pkg/cmd/codespace/common_test.go new file mode 100644 index 000000000..c5fc01590 --- /dev/null +++ b/pkg/cmd/codespace/common_test.go @@ -0,0 +1,132 @@ +package codespace + +import ( + "testing" + + "github.com/cli/cli/v2/internal/codespaces/api" +) + +func Test_codespace_displayName(t *testing.T) { + type fields struct { + Codespace *api.Codespace + } + type args struct { + includeName bool + includeGitStatus bool + } + tests := []struct { + name string + fields fields + args args + want string + }{ + { + name: "No included name or gitstatus", + fields: fields{ + Codespace: &api.Codespace{ + GitStatus: api.CodespaceGitStatus{ + Ref: "trunk", + }, + Repository: api.Repository{ + FullName: "cli/cli", + }, + DisplayName: "scuba steve", + }, + }, + args: args{ + includeName: false, + includeGitStatus: false, + }, + want: "cli/cli: trunk", + }, + { + name: "No included name - included gitstatus - no unsaved changes", + fields: fields{ + Codespace: &api.Codespace{ + GitStatus: api.CodespaceGitStatus{ + Ref: "trunk", + }, + Repository: api.Repository{ + FullName: "cli/cli", + }, + DisplayName: "scuba steve", + }, + }, + args: args{ + includeName: false, + includeGitStatus: true, + }, + want: "cli/cli: trunk", + }, + { + name: "No included name - included gitstatus - unsaved changes", + fields: fields{ + Codespace: &api.Codespace{ + GitStatus: api.CodespaceGitStatus{ + Ref: "trunk", + HasUncommitedChanges: true, + }, + Repository: api.Repository{ + FullName: "cli/cli", + }, + DisplayName: "scuba steve", + }, + }, + args: args{ + includeName: false, + includeGitStatus: true, + }, + want: "cli/cli: trunk*", + }, + { + name: "Included name - included gitstatus - unsaved changes", + fields: fields{ + Codespace: &api.Codespace{ + GitStatus: api.CodespaceGitStatus{ + Ref: "trunk", + HasUncommitedChanges: true, + }, + Repository: api.Repository{ + FullName: "cli/cli", + }, + DisplayName: "scuba steve", + }, + }, + args: args{ + includeName: true, + includeGitStatus: true, + }, + want: "cli/cli: scuba steve (trunk*)", + }, + { + name: "Included name - included gitstatus - no unsaved changes", + fields: fields{ + Codespace: &api.Codespace{ + GitStatus: api.CodespaceGitStatus{ + Ref: "trunk", + HasUncommitedChanges: false, + }, + Repository: api.Repository{ + FullName: "cli/cli", + }, + DisplayName: "scuba steve", + }, + }, + args: args{ + includeName: true, + includeGitStatus: true, + }, + want: "cli/cli: scuba steve (trunk)", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := codespace{ + Codespace: tt.fields.Codespace, + } + if got := c.displayName(tt.args.includeName, tt.args.includeGitStatus); got != tt.want { + t.Errorf("codespace.displayName() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go index ee9d81fe7..b145a6a7d 100644 --- a/pkg/cmd/codespace/create.go +++ b/pkg/cmd/codespace/create.go @@ -9,15 +9,18 @@ import ( "github.com/AlecAivazis/survey/v2" "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/utils" "github.com/spf13/cobra" ) type createOptions struct { - repo string - branch string - machine string - showStatus bool - idleTimeout time.Duration + repo string + branch string + machine string + showStatus bool + permissionsOptOut bool + idleTimeout time.Duration } func newCreateCmd(app *App) *cobra.Command { @@ -35,6 +38,7 @@ func newCreateCmd(app *App) *cobra.Command { createCmd.Flags().StringVarP(&opts.repo, "repo", "r", "", "repository name with owner: user/repo") createCmd.Flags().StringVarP(&opts.branch, "branch", "b", "", "repository branch") createCmd.Flags().StringVarP(&opts.machine, "machine", "m", "", "hardware specifications for the VM") + createCmd.Flags().BoolVarP(&opts.permissionsOptOut, "default-permissions", "", false, "do not prompt to accept additional permissions requested by the codespace") 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\"") @@ -60,8 +64,14 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { } questions := []*survey.Question{ { - Name: "repository", - Prompt: &survey.Input{Message: "Repository:"}, + Name: "repository", + Prompt: &survey.Input{ + Message: "Repository:", + Help: "Search for repos by name. To search within an org or user, or to see private repos, enter at least ':user/'.", + Suggest: func(toComplete string) []string { + return getRepoSuggestions(ctx, a.apiClient, toComplete) + }, + }, Validate: survey.Required, }, { @@ -102,17 +112,30 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { return errors.New("there are no available machine types for this repository") } - a.StartProgressIndicatorWithLabel("Creating codespace") - codespace, err := a.apiClient.CreateCodespace(ctx, &api.CreateCodespaceParams{ + createParams := &api.CreateCodespaceParams{ RepositoryID: repository.ID, Branch: branch, Machine: machine, Location: locationResult.Location, IdleTimeoutMinutes: int(opts.idleTimeout.Minutes()), - }) + PermissionsOptOut: opts.permissionsOptOut, + } + + a.StartProgressIndicatorWithLabel("Creating codespace") + codespace, err := a.apiClient.CreateCodespace(ctx, createParams) a.StopProgressIndicator() + if err != nil { - return fmt.Errorf("error creating codespace: %w", err) + var aerr api.AcceptPermissionsRequiredError + if !errors.As(err, &aerr) || aerr.AllowPermissionsURL == "" { + return fmt.Errorf("error creating codespace: %w", err) + } + + codespace, err = a.handleAdditionalPermissions(ctx, createParams, aerr.AllowPermissionsURL) + if err != nil { + // this error could be a cmdutil.SilentError (in the case that the user opened the browser) so we don't want to wrap it + return err + } } if opts.showStatus { @@ -125,6 +148,71 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { return nil } +func (a *App) handleAdditionalPermissions(ctx context.Context, createParams *api.CreateCodespaceParams, allowPermissionsURL string) (*api.Codespace, error) { + var ( + isInteractive = a.io.CanPrompt() + cs = a.io.ColorScheme() + displayURL = utils.DisplayURL(allowPermissionsURL) + ) + + fmt.Fprintf(a.io.ErrOut, "You must authorize or deny additional permissions requested by this codespace before continuing.\n") + + if !isInteractive { + fmt.Fprintf(a.io.ErrOut, "%s in your browser to review and authorize additional permissions: %s\n", cs.Bold("Open this URL"), displayURL) + fmt.Fprintf(a.io.ErrOut, "Alternatively, you can run %q with the %q option to continue without authorizing additional permissions.\n", a.io.ColorScheme().Bold("create"), cs.Bold("--default-permissions")) + return nil, cmdutil.SilentError + } + + choices := []string{ + "Continue in browser to review and authorize additional permissions", + "Continue without authorizing additional permissions", + } + + permsSurvey := []*survey.Question{ + { + Name: "accept", + Prompt: &survey.Select{ + Message: "What would you like to do?", + Options: choices, + Default: choices[0], + }, + Validate: survey.Required, + }, + } + + var answers struct { + Accept string + } + + if err := ask(permsSurvey, &answers); err != nil { + return nil, fmt.Errorf("error getting answers: %w", err) + } + + // if the user chose to continue in the browser, open the URL + if answers.Accept == choices[0] { + if err := a.browser.Browse(allowPermissionsURL); err != nil { + return nil, fmt.Errorf("error opening browser: %w", err) + } + // browser opened successfully but we do not know if they accepted the permissions + // so we must exit and wait for the user to attempt the create again + return nil, cmdutil.SilentError + } + + // if the user chose to create the codespace without the permissions, + // we can continue with the create opting out of the additional permissions + createParams.PermissionsOptOut = true + + a.StartProgressIndicatorWithLabel("Creating codespace") + codespace, err := a.apiClient.CreateCodespace(ctx, createParams) + a.StopProgressIndicator() + + if err != nil { + return nil, fmt.Errorf("error creating codespace: %w", err) + } + + return codespace, nil +} + // showStatus polls the codespace for a list of post create states and their status. It will keep polling // until all states have finished. Once all states have finished, we poll once more to check if any new // states have been introduced and stop polling otherwise. @@ -265,6 +353,21 @@ func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machin return selectedMachine.Name, nil } +func getRepoSuggestions(ctx context.Context, apiClient apiClient, partialSearch string) []string { + searchParams := api.RepoSearchParameters{ + // The prompt shows 7 items so 7 effectively turns off scrolling which is similar behavior to other clients + MaxRepos: 7, + Sort: "repo", + } + + repos, err := apiClient.GetCodespaceRepoSuggestions(ctx, partialSearch, searchParams) + if err != nil { + return nil + } + + return repos +} + // buildDisplayName returns display name to be used in the machine survey prompt. func buildDisplayName(displayName string, prebuildAvailability string) string { prebuildText := "" diff --git a/pkg/cmd/codespace/create_test.go b/pkg/cmd/codespace/create_test.go index 4b266ffaa..2d417620d 100644 --- a/pkg/cmd/codespace/create_test.go +++ b/pkg/cmd/codespace/create_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" ) @@ -18,7 +19,7 @@ func TestApp_Create(t *testing.T) { name string fields fields opts createOptions - wantErr bool + wantErr error wantStdout string wantStderr string }{ @@ -55,6 +56,9 @@ func TestApp_Create(t *testing.T) { Name: "monalisa-dotfiles-abcd1234", }, nil }, + GetCodespaceRepoSuggestionsFunc: func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) { + return nil, nil // We can't ask for suggestions without a terminal. + }, }, }, opts: createOptions{ @@ -66,6 +70,57 @@ func TestApp_Create(t *testing.T) { }, wantStdout: "monalisa-dotfiles-abcd1234\n", }, + { + name: "create codespace that requires accepting additional permissions", + 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{}, api.AcceptPermissionsRequiredError{ + AllowPermissionsURL: "https://example.com/permissions", + } + }, + GetCodespaceRepoSuggestionsFunc: func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) { + return nil, nil // We can't ask for suggestions without a terminal. + }, + }, + }, + opts: createOptions{ + repo: "monalisa/dotfiles", + branch: "", + machine: "GIGA", + showStatus: false, + idleTimeout: 30 * time.Minute, + }, + wantErr: cmdutil.SilentError, + wantStderr: `You must authorize or deny additional permissions requested by this codespace before continuing. +Open this URL in your browser to review and authorize additional permissions: example.com/permissions +Alternatively, you can run "create" with the "--default-permissions" option to continue without authorizing additional permissions. +`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -74,7 +129,7 @@ func TestApp_Create(t *testing.T) { io: io, apiClient: tt.fields.apiClient, } - if err := a.Create(context.Background(), tt.opts); (err != nil) != tt.wantErr { + if err := a.Create(context.Background(), tt.opts); err != tt.wantErr { t.Errorf("App.Create() error = %v, wantErr %v", err, tt.wantErr) } if got := stdout.String(); got != tt.wantStdout { diff --git a/pkg/cmd/codespace/delete_test.go b/pkg/cmd/codespace/delete_test.go index 58090c809..638641d64 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, nil) 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/list.go b/pkg/cmd/codespace/list.go index 7fe71d6fa..661d1da1c 100644 --- a/pkg/cmd/codespace/list.go +++ b/pkg/cmd/codespace/list.go @@ -16,9 +16,10 @@ func newListCmd(app *App) *cobra.Command { var exporter cmdutil.Exporter listCmd := &cobra.Command{ - Use: "list", - Short: "List your codespaces", - Args: noArgsConstraint, + Use: "list", + Short: "List your codespaces", + Aliases: []string{"ls"}, + Args: noArgsConstraint, RunE: func(cmd *cobra.Command, args []string) error { if limit < 1 { return cmdutil.FlagErrorf("invalid limit: %v", limit) diff --git a/pkg/cmd/codespace/logs.go b/pkg/cmd/codespace/logs.go index 9fc6010b4..d0a0c233b 100644 --- a/pkg/cmd/codespace/logs.go +++ b/pkg/cmd/codespace/logs.go @@ -41,14 +41,9 @@ 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) diff --git a/pkg/cmd/codespace/mock_api.go b/pkg/cmd/codespace/mock_api.go index 8d40934da..fd275e802 100644 --- a/pkg/cmd/codespace/mock_api.go +++ b/pkg/cmd/codespace/mock_api.go @@ -31,6 +31,9 @@ import ( // GetCodespaceRegionLocationFunc: func(ctx context.Context) (string, error) { // panic("mock out the GetCodespaceRegionLocation method") // }, +// GetCodespaceRepoSuggestionsFunc: func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) { +// panic("mock out the GetCodespaceRepoSuggestions method") +// }, // GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) { // panic("mock out the GetCodespaceRepositoryContents method") // }, @@ -74,6 +77,9 @@ type apiClientMock struct { // GetCodespaceRegionLocationFunc mocks the GetCodespaceRegionLocation method. GetCodespaceRegionLocationFunc func(ctx context.Context) (string, error) + // GetCodespaceRepoSuggestionsFunc mocks the GetCodespaceRepoSuggestions method. + GetCodespaceRepoSuggestionsFunc func(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) + // GetCodespaceRepositoryContentsFunc mocks the GetCodespaceRepositoryContents method. GetCodespaceRepositoryContentsFunc func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) @@ -132,6 +138,15 @@ type apiClientMock struct { // Ctx is the ctx argument value. Ctx context.Context } + // GetCodespaceRepoSuggestions holds details about calls to the GetCodespaceRepoSuggestions method. + GetCodespaceRepoSuggestions []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // PartialSearch is the partialSearch argument value. + PartialSearch string + // Params is the params argument value. + Params api.RepoSearchParameters + } // GetCodespaceRepositoryContents holds details about calls to the GetCodespaceRepositoryContents method. GetCodespaceRepositoryContents []struct { // Ctx is the ctx argument value. @@ -191,6 +206,7 @@ type apiClientMock struct { lockDeleteCodespace sync.RWMutex lockGetCodespace sync.RWMutex lockGetCodespaceRegionLocation sync.RWMutex + lockGetCodespaceRepoSuggestions sync.RWMutex lockGetCodespaceRepositoryContents sync.RWMutex lockGetCodespacesMachines sync.RWMutex lockGetRepository sync.RWMutex @@ -375,6 +391,45 @@ func (mock *apiClientMock) GetCodespaceRegionLocationCalls() []struct { return calls } +// GetCodespaceRepoSuggestions calls GetCodespaceRepoSuggestionsFunc. +func (mock *apiClientMock) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error) { + if mock.GetCodespaceRepoSuggestionsFunc == nil { + panic("apiClientMock.GetCodespaceRepoSuggestionsFunc: method is nil but apiClient.GetCodespaceRepoSuggestions was just called") + } + callInfo := struct { + Ctx context.Context + PartialSearch string + Params api.RepoSearchParameters + }{ + Ctx: ctx, + PartialSearch: partialSearch, + Params: params, + } + mock.lockGetCodespaceRepoSuggestions.Lock() + mock.calls.GetCodespaceRepoSuggestions = append(mock.calls.GetCodespaceRepoSuggestions, callInfo) + mock.lockGetCodespaceRepoSuggestions.Unlock() + return mock.GetCodespaceRepoSuggestionsFunc(ctx, partialSearch, params) +} + +// GetCodespaceRepoSuggestionsCalls gets all the calls that were made to GetCodespaceRepoSuggestions. +// Check the length with: +// len(mockedapiClient.GetCodespaceRepoSuggestionsCalls()) +func (mock *apiClientMock) GetCodespaceRepoSuggestionsCalls() []struct { + Ctx context.Context + PartialSearch string + Params api.RepoSearchParameters +} { + var calls []struct { + Ctx context.Context + PartialSearch string + Params api.RepoSearchParameters + } + mock.lockGetCodespaceRepoSuggestions.RLock() + calls = mock.calls.GetCodespaceRepoSuggestions + mock.lockGetCodespaceRepoSuggestions.RUnlock() + return calls +} + // GetCodespaceRepositoryContents calls GetCodespaceRepositoryContentsFunc. func (mock *apiClientMock) GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) { if mock.GetCodespaceRepositoryContentsFunc == nil { diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go index 840386d5c..726f2152f 100644 --- a/pkg/cmd/codespace/ssh.go +++ b/pkg/cmd/codespace/ssh.go @@ -4,16 +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" @@ -25,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') } @@ -34,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, } @@ -45,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 } @@ -55,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) @@ -86,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 { + 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() @@ -101,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 @@ -147,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 @@ -277,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/completion/completion.go b/pkg/cmd/completion/completion.go index 496f06784..d83dd31ef 100644 --- a/pkg/cmd/completion/completion.go +++ b/pkg/cmd/completion/completion.go @@ -92,8 +92,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command { } cmdutil.DisableAuthCheck(cmd) - - cmd.Flags().StringVarP(&shellType, "shell", "s", "", "Shell type: {bash|zsh|fish|powershell}") + cmdutil.StringEnumFlag(cmd, &shellType, "shell", "s", "", []string{"bash", "zsh", "fish", "powershell"}, "Shell type") return cmd } diff --git a/pkg/cmd/completion/completion_test.go b/pkg/cmd/completion/completion_test.go index 06b3d2f27..a5068f747 100644 --- a/pkg/cmd/completion/completion_test.go +++ b/pkg/cmd/completion/completion_test.go @@ -39,7 +39,7 @@ func TestNewCmdCompletion(t *testing.T) { { name: "unsupported shell", args: "completion -s csh", - wantErr: "unsupported shell type \"csh\"", + wantErr: "invalid argument \"csh\" for \"-s, --shell\" flag: valid values are {bash|zsh|fish|powershell}", }, } for _, tt := range tests { 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 index 8b1157b1f..cf1422f0f 100644 --- a/pkg/cmd/config/list/list.go +++ b/pkg/cmd/config/list/list.go @@ -23,9 +23,10 @@ func NewCmdConfigList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra. } cmd := &cobra.Command{ - Use: "list", - Short: "Print a list of configuration keys and values", - Args: cobra.ExactArgs(0), + Use: "list", + Short: "Print a list of configuration keys and values", + Aliases: []string{"ls"}, + Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { if runF != nil { return runF(opts) @@ -59,7 +60,7 @@ func listRun(opts *ListOptions) error { configOptions := config.ConfigOptions() for _, key := range configOptions { - val, err := cfg.Get(host, key.Key) + val, err := cfg.GetOrDefault(host, key.Key) if err != nil { return err } 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 c4a08152b..916940749 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -33,16 +33,17 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { An extension cannot override any of the core gh commands. - See the list of available extensions at + See the list of available extensions at . `, "`"), Aliases: []string{"extensions"}, } extCmd.AddCommand( &cobra.Command{ - Use: "list", - Short: "List installed extension commands", - Args: cobra.NoArgs, + Use: "list", + Short: "List installed extension commands", + Aliases: []string{"ls"}, + Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { cmds := m.List(true) if len(cmds) == 0 { @@ -84,7 +85,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { To install an extension in development from the current directory, use "." as the value of the repository argument. - See the list of available extensions at + See the list of available extensions at . `), Example: heredoc.Doc(` $ gh extension install owner/gh-extension diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index 5a46592f9..8f896eab0 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -316,8 +316,10 @@ func TestNewCmdExtension(t *testing.T) { }, isTTY: true, askStubs: func(as *prompt.AskStubber) { - as.StubOne("test") - as.StubOne(0) + 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 @@ -456,8 +458,7 @@ func TestNewCmdExtension(t *testing.T) { assertFunc = tt.managerStubs(em) } - as, teardown := prompt.InitAskStubber() - defer teardown() + as := prompt.NewAskStubber(t) if tt.askStubs != nil { tt.askStubs(as) } diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index 6ff5f461a..41dab528d 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -334,10 +334,10 @@ func (m *Manager) Install(repo ghrepo.Interface, targetCommitish string) error { return err } if !hs { - 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) } 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: