Merge remote-tracking branch 'origin/trunk' into issue703

This commit is contained in:
nate smith 2022-01-20 12:11:26 -06:00
commit 44f9d03a85
303 changed files with 18460 additions and 4062 deletions

15
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,15 @@
version: 2
updates:
- package-ecosystem: gomod
directory: "/"
schedule:
interval: "daily"
ignore:
- dependency-name: "*"
update-types:
- version-update:semver-minor
- version-update:semver-major
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

View file

@ -2,7 +2,11 @@ name: Code Scanning
on:
push:
branches: [trunk]
pull_request:
branches: [trunk]
paths-ignore:
- '**/*.md'
schedule:
- cron: "0 0 * * 0"

View file

@ -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

19
.github/workflows/issueauto.yml vendored Normal file
View file

@ -0,0 +1,19 @@
name: Issue Automation
on:
issues:
types: [opened]
jobs:
issue-auto:
runs-on: ubuntu-latest
steps:
- name: label incoming issue
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.AUTOMATION_TOKEN }}
ISSUENUM: ${{ github.event.issue.number }}
ISSUEAUTHOR: ${{ github.event.issue.user.login }}
run: |
if ! gh api orgs/cli/public_members/$ISSUEAUTHOR --silent 2>/dev/null
then
gh issue edit $ISSUENUM --add-label "needs-triage"
fi

View file

@ -15,6 +15,7 @@ jobs:
PRNUM: ${{ github.event.pull_request.number }}
PRHEAD: ${{ github.event.pull_request.head.label }}
PRAUTHOR: ${{ github.event.pull_request.user.login }}
PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
if: "!github.event.pull_request.draft"
run: |
commentPR () {
@ -42,8 +43,12 @@ jobs:
' -f colID="$(colID "Needs review")" -f prID="$PRID"
}
if gh api orgs/cli/public_members/$PRAUTHOR --silent 2>/dev/null
if [ "$PR_AUTHOR_TYPE" = "Bot" ] || gh api orgs/cli/public_members/$PRAUTHOR --silent 2>/dev/null
then
if [ "$PR_AUTHOR_TYPE" != "Bot" ]
then
gh pr edit $PRNUM --add-assignee $PRAUTHOR
fi
if ! errtext="$(addToBoard 2>&1)"
then
cat <<<"$errtext" >&2
@ -55,6 +60,8 @@ jobs:
exit 0
fi
gh pr edit $PRNUM --add-label "external"
if [ "$PRHEAD" = "cli:trunk" ]
then
closePR

View file

@ -16,10 +16,17 @@ jobs:
with:
go-version: 1.16
- name: Generate changelog
id: changelog
run: |
echo "GORELEASER_CURRENT_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
git fetch --unshallow
script/changelog | tee CHANGELOG.md
echo "::set-output name=tag-name::${GITHUB_REF#refs/tags/}"
gh api repos/$GITHUB_REPOSITORY/releases/generate-notes \
-f tag_name="${GITHUB_REF#refs/tags/}" \
-f target_commitish=trunk \
-q .body > CHANGELOG.md
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Install osslsigncode
run: sudo apt-get install -y osslsigncode
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
with:
@ -27,13 +34,16 @@ jobs:
args: release --release-notes=CHANGELOG.md
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
GORELEASER_CURRENT_TAG: ${{steps.changelog.outputs.tag-name}}
GITHUB_CERT_PASSWORD: ${{secrets.GITHUB_CERT_PASSWORD}}
DESKTOP_CERT_TOKEN: ${{secrets.DESKTOP_CERT_TOKEN}}
- name: Checkout documentation site
uses: actions/checkout@v2
with:
repository: github/cli.github.com
path: site
fetch-depth: 0
token: ${{secrets.SITE_GITHUB_TOKEN}}
ssh-key: ${{secrets.SITE_SSH_KEY}}
- name: Update site man pages
env:
GIT_COMMITTER_NAME: cli automation
@ -55,7 +65,6 @@ jobs:
api-write --silent projects/columns/cards/$card/moves -f position=top -F column_id=$DONE_COLUMN
done
echo "moved ${#cards[@]} cards to the Done column"
- name: Install packaging dependencies
run: sudo apt-get install -y rpm reprepro
- name: Set up GPG

View file

@ -8,7 +8,8 @@ release:
before:
hooks:
- go mod tidy
- make manpages
- make manpages GH_VERSION={{.Version}}
- ./script/prepare-windows-cert.sh '{{ if index .Env "GITHUB_CERT_PASSWORD" }}{{ .Env.GITHUB_CERT_PASSWORD}}{{ end }}' '{{ if index .Env "DESKTOP_CERT_TOKEN" }}{{ .Env.DESKTOP_CERT_TOKEN}}{{ end }}'
builds:
- <<: &build_defaults
@ -32,6 +33,9 @@ builds:
id: windows
goos: [windows]
goarch: [386, amd64]
hooks:
post:
- ./script/sign-windows-executable.sh '{{ .Path }}'
archives:
- id: nix

5
CODEOWNERS Normal file
View file

@ -0,0 +1,5 @@
* @cli/code-reviewers
pkg/cmd/codespace/ @cli/codespaces
pkg/liveshare/ @cli/codespaces
internal/codespaces/ @cli/codespaces

View file

@ -19,7 +19,7 @@ If anything feels off, or if you feel that some functionality is missing, please
### macOS
`gh` is available via [Homebrew][], [MacPorts][], [Conda][], and as a downloadable binary from the [releases page][].
`gh` is available via [Homebrew][], [MacPorts][], [Conda][], [Spack][], and as a downloadable binary from the [releases page][].
#### Homebrew
@ -41,21 +41,27 @@ If anything feels off, or if you feel that some functionality is missing, please
Additional Conda installation options available on the [gh-feedstock page](https://github.com/conda-forge/gh-feedstock#installing-gh).
#### Spack
| Install: | Upgrade: |
| ------------------ | ---------------------------------------- |
| `spack install gh` | `spack uninstall gh && spack install gh` |
### Linux & BSD
`gh` is available via [Homebrew](#homebrew), [Conda](#Conda), and as downloadable binaries from the [releases page][].
`gh` is available via [Homebrew](#homebrew), [Conda](#conda), [Spack](#spack), and as downloadable binaries from the [releases page][].
For instructions on specific distributions and package managers, see [Linux & BSD installation](./docs/install_linux.md).
### Windows
`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#Conda), and as downloadable MSI.
`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#conda), and as downloadable MSI.
#### WinGet
| Install: | Upgrade: |
| ------------------- | --------------------|
| `winget install gh` | `winget upgrade gh` |
| `winget install --id GitHub.cli` | `winget upgrade --id GitHub.cli` |
#### scoop
@ -99,6 +105,7 @@ tool. Check out our [more detailed explanation][gh-vs-hub] to learn more.
[scoop]: https://scoop.sh
[Chocolatey]: https://chocolatey.org
[Conda]: https://docs.conda.io/en/latest/
[Spack]: https://spack.io
[releases page]: https://github.com/cli/cli/releases/latest
[hub]: https://github.com/github/hub
[contributing]: ./.github/CONTRIBUTING.md

View file

@ -12,8 +12,8 @@ import (
"strings"
"github.com/cli/cli/v2/internal/ghinstance"
graphql "github.com/cli/shurcooL-graphql"
"github.com/henvic/httpretty"
"github.com/shurcooL/graphql"
)
// ClientOption represents an argument to NewClient
@ -98,6 +98,22 @@ func ReplaceTripper(tr http.RoundTripper) ClientOption {
}
}
// ExtractHeader extracts a named header from any response received by this client and, if non-blank, saves
// it to dest.
func ExtractHeader(name string, dest *string) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
res, err := tr.RoundTrip(req)
if err == nil {
if value := res.Header.Get(name); value != "" {
*dest = value
}
}
return res, err
}}
}
}
type funcTripper struct {
roundTrip func(*http.Request) (*http.Response, error)
}
@ -124,7 +140,18 @@ type graphQLResponse struct {
type GraphQLError struct {
Type string
Message string
// Path []interface // mixed strings and numbers
Path []interface{} // mixed strings and numbers
}
func (ge GraphQLError) PathString() string {
var res strings.Builder
for i, v := range ge.Path {
if i > 0 {
res.WriteRune('.')
}
fmt.Fprintf(&res, "%v", v)
}
return res.String()
}
// GraphQLErrorResponse contains errors returned in a GraphQL response
@ -135,18 +162,41 @@ type GraphQLErrorResponse struct {
func (gr GraphQLErrorResponse) Error() string {
errorMessages := make([]string, 0, len(gr.Errors))
for _, e := range gr.Errors {
errorMessages = append(errorMessages, e.Message)
msg := e.Message
if p := e.PathString(); p != "" {
msg = fmt.Sprintf("%s (%s)", msg, p)
}
errorMessages = append(errorMessages, msg)
}
return fmt.Sprintf("GraphQL error: %s", strings.Join(errorMessages, "\n"))
return fmt.Sprintf("GraphQL: %s", strings.Join(errorMessages, ", "))
}
// Match checks if this error is only about a specific type on a specific path. If the path argument ends
// with a ".", it will match all its subpaths as well.
func (gr GraphQLErrorResponse) Match(expectType, expectPath string) bool {
for _, e := range gr.Errors {
if e.Type != expectType || !matchPath(e.PathString(), expectPath) {
return false
}
}
return true
}
func matchPath(p, expect string) bool {
if strings.HasSuffix(expect, ".") {
return strings.HasPrefix(p, expect) || p == strings.TrimSuffix(expect, ".")
}
return p == expect
}
// HTTPError is an error returned by a failed API call
type HTTPError struct {
StatusCode int
RequestURL *url.URL
Message string
OAuthScopes string
Errors []HTTPErrorItem
StatusCode int
RequestURL *url.URL
Message string
Errors []HTTPErrorItem
scopesSuggestion string
}
type HTTPErrorItem struct {
@ -165,7 +215,63 @@ func (err HTTPError) Error() string {
return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL)
}
// GraphQL performs a GraphQL request and parses the response
func (err HTTPError) ScopesSuggestion() string {
return err.scopesSuggestion
}
// ScopesSuggestion is an error messaging utility that prints the suggestion to request additional OAuth
// scopes in case a server response indicates that there are missing scopes.
func ScopesSuggestion(resp *http.Response) string {
if resp.StatusCode < 400 || resp.StatusCode > 499 || resp.StatusCode == 422 {
return ""
}
endpointNeedsScopes := resp.Header.Get("X-Accepted-Oauth-Scopes")
tokenHasScopes := resp.Header.Get("X-Oauth-Scopes")
if tokenHasScopes == "" {
return ""
}
gotScopes := map[string]struct{}{}
for _, s := range strings.Split(tokenHasScopes, ",") {
s = strings.TrimSpace(s)
gotScopes[s] = struct{}{}
if strings.HasPrefix(s, "admin:") {
gotScopes["read:"+strings.TrimPrefix(s, "admin:")] = struct{}{}
gotScopes["write:"+strings.TrimPrefix(s, "admin:")] = struct{}{}
} else if strings.HasPrefix(s, "write:") {
gotScopes["read:"+strings.TrimPrefix(s, "write:")] = struct{}{}
}
}
for _, s := range strings.Split(endpointNeedsScopes, ",") {
s = strings.TrimSpace(s)
if _, gotScope := gotScopes[s]; s == "" || gotScope {
continue
}
return fmt.Sprintf(
"This API operation needs the %[1]q scope. To request it, run: gh auth refresh -h %[2]s -s %[1]s",
s,
ghinstance.NormalizeHostname(resp.Request.URL.Hostname()),
)
}
return ""
}
// EndpointNeedsScopes adds additional OAuth scopes to an HTTP response as if they were returned from the
// server endpoint. This improves HTTP 4xx error messaging for endpoints that don't explicitly list the
// OAuth scopes they need.
func EndpointNeedsScopes(resp *http.Response, s string) *http.Response {
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
oldScopes := resp.Header.Get("X-Accepted-Oauth-Scopes")
resp.Header.Set("X-Accepted-Oauth-Scopes", fmt.Sprintf("%s, %s", oldScopes, s))
}
return resp
}
// GraphQL performs a GraphQL request and parses the response. If there are errors in the response,
// *GraphQLErrorResponse will be returned, but the data will also be parsed into the receiver.
func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error {
reqBody, err := json.Marshal(map[string]interface{}{"query": query, "variables": variables})
if err != nil {
@ -261,9 +367,9 @@ func handleResponse(resp *http.Response, data interface{}) error {
func HandleHTTPError(resp *http.Response) error {
httpError := HTTPError{
StatusCode: resp.StatusCode,
RequestURL: resp.Request.URL,
OAuthScopes: resp.Header.Get("X-Oauth-Scopes"),
StatusCode: resp.StatusCode,
RequestURL: resp.Request.URL,
scopesSuggestion: ScopesSuggestion(resp),
}
if !jsonTypeRE.MatchString(resp.Header.Get("Content-Type")) {

View file

@ -50,15 +50,23 @@ func TestGraphQLError(t *testing.T) {
httpmock.GraphQL(""),
httpmock.StringResponse(`
{ "errors": [
{"message":"OH NO"},
{"message":"this is fine"}
{
"type": "NOT_FOUND",
"message": "OH NO",
"path": ["repository", "issue"]
},
{
"type": "ACTUALLY_ITS_FINE",
"message": "this is fine",
"path": ["repository", "issues", 0, "comments"]
}
]
}
`),
)
err := client.GraphQL("github.com", "", nil, &response)
if err == nil || err.Error() != "GraphQL error: OH NO\nthis is fine" {
if err == nil || err.Error() != "GraphQL: OH NO (repository.issue), this is fine (repository.issues.0.comments)" {
t.Fatalf("got %q", err.Error())
}
}
@ -146,3 +154,72 @@ func TestHandleHTTPError_GraphQL502(t *testing.T) {
t.Errorf("got error: %v", err)
}
}
func TestHTTPError_ScopesSuggestion(t *testing.T) {
makeResponse := func(s int, u, haveScopes, needScopes string) *http.Response {
req, err := http.NewRequest("GET", u, nil)
if err != nil {
t.Fatal(err)
}
return &http.Response{
Request: req,
StatusCode: s,
Body: ioutil.NopCloser(bytes.NewBufferString(`{}`)),
Header: map[string][]string{
"Content-Type": {"application/json"},
"X-Oauth-Scopes": {haveScopes},
"X-Accepted-Oauth-Scopes": {needScopes},
},
}
}
tests := []struct {
name string
resp *http.Response
want string
}{
{
name: "has necessary scopes",
resp: makeResponse(404, "https://api.github.com/gists", "repo, gist, read:org", "gist"),
want: ``,
},
{
name: "normalizes scopes",
resp: makeResponse(404, "https://api.github.com/orgs/ORG/discussions", "admin:org, write:discussion", "read:org, read:discussion"),
want: ``,
},
{
name: "no scopes on endpoint",
resp: makeResponse(404, "https://api.github.com/user", "repo", ""),
want: ``,
},
{
name: "missing a scope",
resp: makeResponse(404, "https://api.github.com/gists", "repo, read:org", "gist, delete_repo"),
want: `This API operation needs the "gist" scope. To request it, run: gh auth refresh -h github.com -s gist`,
},
{
name: "server error",
resp: makeResponse(500, "https://api.github.com/gists", "repo", "gist"),
want: ``,
},
{
name: "no scopes on token",
resp: makeResponse(404, "https://api.github.com/gists", "", "gist, delete_repo"),
want: ``,
},
{
name: "http code is 422",
resp: makeResponse(422, "https://api.github.com/gists", "", "gist"),
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
httpError := HandleHTTPError(tt.resp)
if got := httpError.(HTTPError).ScopesSuggestion(); got != tt.want {
t.Errorf("HTTPError.ScopesSuggestion() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -5,7 +5,7 @@ import (
"strings"
)
func (issue *Issue) ExportData(fields []string) *map[string]interface{} {
func (issue *Issue) ExportData(fields []string) map[string]interface{} {
v := reflect.ValueOf(issue).Elem()
data := map[string]interface{}{}
@ -25,10 +25,10 @@ func (issue *Issue) ExportData(fields []string) *map[string]interface{} {
}
}
return &data
return data
}
func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} {
func (pr *PullRequest) ExportData(fields []string) map[string]interface{} {
v := reflect.ValueOf(pr).Elem()
data := map[string]interface{}{}
@ -102,7 +102,7 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} {
}
}
return &data
return data
}
func fieldByName(v reflect.Value, field string) reflect.Value {

View file

@ -4,7 +4,7 @@ import (
"reflect"
)
func (repo *Repository) ExportData(fields []string) *map[string]interface{} {
func (repo *Repository) ExportData(fields []string) map[string]interface{} {
v := reflect.ValueOf(repo).Elem()
data := map[string]interface{}{}
@ -38,7 +38,7 @@ func (repo *Repository) ExportData(fields []string) *map[string]interface{} {
}
}
return &data
return data
}
func miniRepoExport(r *Repository) map[string]interface{} {

View file

@ -4,8 +4,8 @@ import (
"context"
"time"
graphql "github.com/cli/shurcooL-graphql"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
)
type Comments struct {

View file

@ -1,12 +1,10 @@
package api
import (
"context"
"fmt"
"time"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/shurcooL/githubv4"
)
type IssuesPayload struct {
@ -16,11 +14,13 @@ type IssuesPayload struct {
}
type IssuesAndTotalCount struct {
Issues []Issue
TotalCount int
Issues []Issue
TotalCount int
SearchCapped bool
}
type Issue struct {
Typename string `json:"__typename"`
ID string
Number int
Title string
@ -40,6 +40,10 @@ type Issue struct {
ReactionGroups ReactionGroups
}
func (i Issue) IsPullRequest() bool {
return i.Typename == "PullRequest"
}
type Assignees struct {
Nodes []GitHubUser
TotalCount int
@ -67,17 +71,19 @@ func (l Labels) Names() []string {
}
type ProjectCards struct {
Nodes []struct {
Project struct {
Name string `json:"name"`
} `json:"project"`
Column struct {
Name string `json:"name"`
} `json:"column"`
}
Nodes []*ProjectInfo
TotalCount int
}
type ProjectInfo struct {
Project struct {
Name string `json:"name"`
} `json:"project"`
Column struct {
Name string `json:"name"`
} `json:"column"`
}
func (p ProjectCards) ProjectNames() []string {
names := make([]string, len(p.Nodes))
for i, c := range p.Nodes {
@ -229,194 +235,6 @@ func IssueStatus(client *Client, repo ghrepo.Interface, options IssueStatusOptio
return &payload, nil
}
func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, error) {
type response struct {
Repository struct {
Issue Issue
HasIssuesEnabled bool
}
}
query := `
query IssueByNumber($owner: String!, $repo: String!, $issue_number: Int!) {
repository(owner: $owner, name: $repo) {
hasIssuesEnabled
issue(number: $issue_number) {
id
title
state
body
author {
login
}
comments(last: 1) {
nodes {
author {
login
}
authorAssociation
body
createdAt
includesCreatedEdit
isMinimized
minimizedReason
reactionGroups {
content
users {
totalCount
}
}
}
totalCount
}
number
url
createdAt
assignees(first: 100) {
nodes {
id
name
login
}
totalCount
}
labels(first: 100) {
nodes {
id
name
description
color
}
totalCount
}
projectCards(first: 100) {
nodes {
project {
name
}
column {
name
}
}
totalCount
}
milestone {
number
title
description
dueOn
}
reactionGroups {
content
users {
totalCount
}
}
}
}
}`
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"issue_number": number,
}
var resp response
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
return nil, err
}
if !resp.Repository.HasIssuesEnabled {
return nil, &IssuesDisabledError{fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))}
}
return &resp.Repository.Issue, nil
}
func IssueClose(client *Client, repo ghrepo.Interface, issue Issue) error {
var mutation struct {
CloseIssue struct {
Issue struct {
ID githubv4.ID
}
} `graphql:"closeIssue(input: $input)"`
}
variables := map[string]interface{}{
"input": githubv4.CloseIssueInput{
IssueID: issue.ID,
},
}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.MutateNamed(context.Background(), "IssueClose", &mutation, variables)
if err != nil {
return err
}
return nil
}
func IssueReopen(client *Client, repo ghrepo.Interface, issue Issue) error {
var mutation struct {
ReopenIssue struct {
Issue struct {
ID githubv4.ID
}
} `graphql:"reopenIssue(input: $input)"`
}
variables := map[string]interface{}{
"input": githubv4.ReopenIssueInput{
IssueID: issue.ID,
},
}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.MutateNamed(context.Background(), "IssueReopen", &mutation, variables)
return err
}
func IssueDelete(client *Client, repo ghrepo.Interface, issue Issue) error {
var mutation struct {
DeleteIssue struct {
Repository struct {
ID githubv4.ID
}
} `graphql:"deleteIssue(input: $input)"`
}
variables := map[string]interface{}{
"input": githubv4.DeleteIssueInput{
IssueID: issue.ID,
},
}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.MutateNamed(context.Background(), "IssueDelete", &mutation, variables)
return err
}
func IssueUpdate(client *Client, repo ghrepo.Interface, params githubv4.UpdateIssueInput) error {
var mutation struct {
UpdateIssue struct {
Issue struct {
ID string
}
} `graphql:"updateIssue(input: $input)"`
}
variables := map[string]interface{}{"input": params}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.MutateNamed(context.Background(), "IssueUpdate", &mutation, variables)
return err
}
func (i Issue) Link() string {
return i.URL
}

View file

@ -2,9 +2,7 @@ package api
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
@ -26,6 +24,7 @@ type PullRequestsPayload struct {
type PullRequestAndTotalCount struct {
TotalCount int
PullRequests []PullRequest
SearchCapped bool
}
type PullRequest struct {
@ -266,30 +265,6 @@ func (pr *PullRequest) DisplayableReviews() PullRequestReviews {
return PullRequestReviews{Nodes: published, TotalCount: len(published)}
}
func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.ReadCloser, error) {
url := fmt.Sprintf("%srepos/%s/pulls/%d",
ghinstance.RESTPrefix(baseRepo.RepoHost()), ghrepo.FullName(baseRepo), prNumber)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3.diff; charset=utf-8")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == 404 {
return nil, errors.New("pull request not found")
} else if resp.StatusCode != 200 {
return nil, HandleHTTPError(resp)
}
return resp.Body, nil
}
type pullRequestFeature struct {
HasReviewDecision bool
HasStatusCheckRollup bool
@ -646,20 +621,6 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
return pr, nil
}
func UpdatePullRequest(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestInput) error {
var mutation struct {
UpdatePullRequest struct {
PullRequest struct {
ID string
}
} `graphql:"updatePullRequest(input: $input)"`
}
variables := map[string]interface{}{"input": params}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.MutateNamed(context.Background(), "PullRequestUpdate", &mutation, variables)
return err
}
func UpdatePullRequestReviews(client *Client, repo ghrepo.Interface, params githubv4.RequestReviewsInput) error {
var mutation struct {
RequestReviews struct {
@ -685,7 +646,7 @@ func isBlank(v interface{}) bool {
}
}
func PullRequestClose(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
func PullRequestClose(httpClient *http.Client, repo ghrepo.Interface, prID string) error {
var mutation struct {
ClosePullRequest struct {
PullRequest struct {
@ -696,17 +657,15 @@ func PullRequestClose(client *Client, repo ghrepo.Interface, pr *PullRequest) er
variables := map[string]interface{}{
"input": githubv4.ClosePullRequestInput{
PullRequestID: pr.ID,
PullRequestID: prID,
},
}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.MutateNamed(context.Background(), "PullRequestClose", &mutation, variables)
return err
gql := graphQLClient(httpClient, repo.RepoHost())
return gql.MutateNamed(context.Background(), "PullRequestClose", &mutation, variables)
}
func PullRequestReopen(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
func PullRequestReopen(httpClient *http.Client, repo ghrepo.Interface, prID string) error {
var mutation struct {
ReopenPullRequest struct {
PullRequest struct {
@ -717,14 +676,12 @@ func PullRequestReopen(client *Client, repo ghrepo.Interface, pr *PullRequest) e
variables := map[string]interface{}{
"input": githubv4.ReopenPullRequestInput{
PullRequestID: pr.ID,
PullRequestID: prID,
},
}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.MutateNamed(context.Background(), "PullRequestReopen", &mutation, variables)
return err
gql := graphQLClient(httpClient, repo.RepoHost())
return gql.MutateNamed(context.Background(), "PullRequestReopen", &mutation, variables)
}
func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) error {

View file

@ -230,6 +230,36 @@ func (r Repository) ViewerCanTriage() bool {
}
}
func FetchRepository(client *Client, repo ghrepo.Interface, fields []string) (*Repository, error) {
query := fmt.Sprintf(`query RepositoryInfo($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {%s}
}`, RepositoryGraphQL(fields))
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"name": repo.RepoName(),
}
var result struct {
Repository *Repository
}
if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil {
return nil, err
}
// The GraphQL API should have returned an error in case of a missing repository, but this isn't
// guaranteed to happen when an authentication token with insufficient permissions is being used.
if result.Repository == nil {
return nil, GraphQLErrorResponse{
Errors: []GraphQLError{{
Type: "NOT_FOUND",
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
}},
}
}
return InitRepoHostname(result.Repository, repo.RepoHost()), nil
}
func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
query := `
fragment repo on Repository {
@ -261,16 +291,24 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
"name": repo.RepoName(),
}
result := struct {
Repository Repository
}{}
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
if err != nil {
var result struct {
Repository *Repository
}
if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil {
return nil, err
}
// The GraphQL API should have returned an error in case of a missing repository, but this isn't
// guaranteed to happen when an authentication token with insufficient permissions is being used.
if result.Repository == nil {
return nil, GraphQLErrorResponse{
Errors: []GraphQLError{{
Type: "NOT_FOUND",
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
}},
}
}
return InitRepoHostname(&result.Repository, repo.RepoHost()), nil
return InitRepoHostname(result.Repository, repo.RepoHost()), nil
}
func RepoDefaultBranch(client *Client, repo ghrepo.Interface) (string, error) {
@ -486,6 +524,26 @@ func ForkRepo(client *Client, repo ghrepo.Interface, org string) (*Repository, e
}, nil
}
func LastCommit(client *Client, repo ghrepo.Interface) (*Commit, error) {
var responseData struct {
Repository struct {
DefaultBranchRef struct {
Target struct {
Commit `graphql:"... on Commit"`
}
}
} `graphql:"repository(owner: $owner, name: $repo)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()), "repo": githubv4.String(repo.RepoName()),
}
gql := graphQLClient(client.http, repo.RepoHost())
if err := gql.QueryNamed(context.Background(), "LastCommit", &responseData, variables); err != nil {
return nil, err
}
return &responseData.Repository.DefaultBranchRef.Target.Commit, nil
}
// RepoFindForks finds forks of the repo that are affiliated with the viewer
func RepoFindForks(client *Client, repo ghrepo.Interface, limit int) ([]*Repository, error) {
result := struct {
@ -703,7 +761,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
go func() {
teams, err := OrganizationTeams(client, repo)
// TODO: better detection of non-org repos
if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
if err != nil && !strings.Contains(err.Error(), "Could not resolve to an Organization") {
errc <- fmt.Errorf("error fetching organization teams: %w", err)
return
}
@ -914,7 +972,7 @@ func RepoAndOrgProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, e
orgProjects, err := OrganizationProjects(client, repo)
// TODO: better detection of non-org repos
if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
if err != nil && !strings.Contains(err.Error(), "Could not resolve to an Organization") {
return projects, fmt.Errorf("error fetching organization projects: %w", err)
}
projects = append(projects, orgProjects...)
@ -925,6 +983,15 @@ func RepoAndOrgProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, e
type RepoAssignee struct {
ID string
Login string
Name string
}
// DisplayName returns a formatted string that uses Login and Name to be displayed e.g. 'Login (Name)' or 'Login'
func (ra RepoAssignee) DisplayName() string {
if ra.Name != "" {
return fmt.Sprintf("%s (%s)", ra.Login, ra.Name)
}
return ra.Login
}
// RepoAssignableUsers fetches all the assignable users for a repository

View file

@ -10,6 +10,27 @@ import (
"github.com/cli/cli/v2/pkg/httpmock"
)
func TestGitHubRepo_notFound(t *testing.T) {
httpReg := &httpmock.Registry{}
defer httpReg.Verify(t)
httpReg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`{ "data": { "repository": null } }`))
client := NewClient(ReplaceTripper(httpReg))
repo, err := GitHubRepo(client, ghrepo.New("OWNER", "REPO"))
if err == nil {
t.Fatal("GitHubRepo did not return an error")
}
if wants := "GraphQL: Could not resolve to a Repository with the name 'OWNER/REPO'."; err.Error() != wants {
t.Errorf("GitHubRepo error: want %q, got %q", wants, err.Error())
}
if repo != nil {
t.Errorf("GitHubRepo: expected nil repo, got %v", repo)
}
}
func Test_RepoMetadata(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
@ -341,3 +362,28 @@ func Test_RepoMilestones(t *testing.T) {
}
}
}
func TestDisplayName(t *testing.T) {
tests := []struct {
name string
assignee RepoAssignee
want string
}{
{
name: "assignee with name",
assignee: RepoAssignee{"123", "octocat123", "Octavious Cath"},
want: "octocat123 (Octavious Cath)",
},
{
name: "assignee without name",
assignee: RepoAssignee{"123", "octocat123", ""},
want: "octocat123",
},
}
for _, tt := range tests {
actual := tt.assignee.DisplayName()
if actual != tt.want {
t.Errorf("display name was %s wanted %s", actual, tt.want)
}
}
}

View file

@ -35,6 +35,22 @@ var issueComments = shortenQuery(`
}
`)
var issueCommentLast = shortenQuery(`
comments(last: 1) {
nodes {
author{login},
authorAssociation,
body,
createdAt,
includesCreatedEdit,
isMinimized,
minimizedReason,
reactionGroups{content,users{totalCount}}
},
totalCount
}
`)
var prReviewRequests = shortenQuery(`
reviewRequests(first: 100) {
nodes {
@ -62,6 +78,7 @@ var prReviews = shortenQuery(`
reactionGroups{content,users{totalCount}}
}
pageInfo{hasNextPage,endCursor}
totalCount
}
`)
@ -176,6 +193,8 @@ var PullRequestFields = append(IssueFields,
"statusCheckRollup",
)
// PullRequestGraphQL constructs a GraphQL query fragment for a set of pull request fields. Since GitHub
// pull requests are also technically issues, this function can be used to query issues as well.
func PullRequestGraphQL(fields []string) string {
var q []string
for _, field := range fields {
@ -204,6 +223,8 @@ func PullRequestGraphQL(fields []string) string {
q = append(q, `potentialMergeCommit{oid}`)
case "comments":
q = append(q, issueComments)
case "lastComment": // pseudo-field
q = append(q, issueCommentLast)
case "reviewRequests":
q = append(q, prReviewRequests)
case "reviews":

View file

@ -58,13 +58,7 @@ func run(args []string) error {
}
if *manPage {
header := &docs.GenManHeader{
Title: "gh",
Section: "1",
Source: "",
Manual: "",
}
if err := docs.GenManTree(rootCmd, header, *dir); err != nil {
if err := docs.GenManTree(rootCmd, *dir); err != nil {
return err
}
}

View file

@ -18,7 +18,7 @@ func Test_run(t *testing.T) {
if err != nil {
t.Fatalf("error reading `gh-issue-create.1`: %v", err)
}
if !strings.Contains(string(manPage), `\fBgh issue create`) {
if !strings.Contains(string(manPage), `\fB\fCgh issue create`) {
t.Fatal("man page corrupted")
}

View file

@ -224,8 +224,11 @@ func mainRun() exitCode {
var httpErr api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == 401 {
fmt.Fprintln(stderr, "Try authenticating with: gh auth login")
} else if strings.Contains(err.Error(), "Resource protected by organization SAML enforcement") {
fmt.Fprintln(stderr, "Try re-authenticating with: gh auth refresh")
} else if u := factory.SSOURL(); u != "" {
// handles organization SAML enforcement error
fmt.Fprintf(stderr, "Authorize in your web browser: %s\n", u)
} else if msg := httpErr.ScopesSuggestion(); msg != "" {
fmt.Fprintln(stderr, msg)
}
return exitError
@ -236,7 +239,7 @@ func mainRun() exitCode {
newRelease := <-updateMessageChan
if newRelease != nil {
isHomebrew := isUnderHomebrew(cmdFactory.Executable)
isHomebrew := isUnderHomebrew(cmdFactory.Executable())
if isHomebrew && isRecentRelease(newRelease.PublishedAt) {
// do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core
return exitOK

View file

@ -49,7 +49,7 @@ check your internet connection or https://githubstatus.com
{
name: "Cobra flag error",
args: args{
err: &cmdutil.FlagError{Err: errors.New("unknown flag --foo")},
err: cmdutil.FlagErrorf("unknown flag --foo"),
cmd: cmd,
debug: false,
},

View file

@ -14,14 +14,12 @@ our release schedule.
Install:
```bash
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo gpg --dearmor -o /usr/share/keyrings/githubcli-archive-keyring.gpg
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh
```
**Note**: If you get the error _"gpg: failed to start the dirmngr '/usr/bin/dirmngr': No such file or directory"_, try installing the `dirmngr` package: `sudo apt install dirmngr`.
Upgrade:
```bash
@ -106,6 +104,20 @@ Or via [pkg(8)](https://www.freebsd.org/cgi/man.cgi?pkg(8)):
pkg install gh
```
### NetBSD/pkgsrc
NetBSD users and those on [platforms supported by pkgsrc](https://pkgsrc.org/#index4h1) can install the [gh package](https://pkgsrc.se/net/gh):
```bash
pkgin install gh
```
To install from source:
```bash
cd /usr/pkgsrc/net/gh && make package-install
```
### OpenBSD
In -current, or in releases starting from 7.0, OpenBSD users can install from packages:

View file

@ -24,7 +24,7 @@ commands is:
pkg/cmd/<command>/<subcommand>/<subcommand>.go
```
Following the above example, the main implementation for the `gh issue list` command, including its help
text, is in [pkg/cmd/issue/view/view.go](../pkg/cmd/issue/view/view.go)
text, is in [pkg/cmd/issue/list/list.go](../pkg/cmd/issue/list/list.go)
Other help topics not specific to any command, for example `gh help environment`, are found in
[pkg/cmd/root/help_topic.go](../pkg/cmd/root/help_topic.go).
@ -60,14 +60,14 @@ and talk through which code gets run in order.
## How to add a new command
0. First, check on our issue tracker to verify that our team had approved the plans for a new command.
1. Create a package for the new command, e.g. for a new command `gh boom` create the following directory
1. First, check on our issue tracker to verify that our team had approved the plans for a new command.
2. Create a package for the new command, e.g. for a new command `gh boom` create the following directory
structure: `pkg/cmd/boom/`
2. The new package should expose a method, e.g. `NewCmdBoom()`, that accepts a `*cmdutil.Factory` type and
3. The new package should expose a method, e.g. `NewCmdBoom()`, that accepts a `*cmdutil.Factory` type and
returns a `*cobra.Command`.
* Any logic specific to this command should be kept within the command's package and not added to any
"global" packages like `api` or `utils`.
3. Use the method from the previous step to generate the command and add it to the command tree, typically
4. Use the method from the previous step to generate the command and add it to the command tree, typically
somewhere in the `NewCmdRoot()` method.
## How to write tests

View file

@ -1,6 +1,6 @@
# Releasing
Our build system automatically compiles and attaches cross-platform binaries to any git tag named `vX.Y.Z`. The automated changelog is generated from commit messages starting with “Merge pull request …” that landed between this tag and the previous one (as determined topologically by git).
Our build system automatically compiles and attaches cross-platform binaries to any git tag named `vX.Y.Z`. The changelog is [generated from git commit log](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes).
Users who run official builds of `gh` on their machines will get notified about the new version within a 24 hour period.
@ -31,6 +31,6 @@ If the build fails, there is not a clean way to re-run it. The easiest way would
A local release can be created for testing without creating anything official on the release page.
0. Make sure GoReleaser is installed: `brew install goreleaser`
1. `goreleaser --skip-validate --skip-publish --rm-dist`
2. Find the built products under `dist/`.
1. Make sure GoReleaser is installed: `brew install goreleaser`
2. `goreleaser --skip-validate --skip-publish --rm-dist`
3. Find the built products under `dist/`.

View file

@ -1,6 +1,6 @@
# Installation from source
0. Verify that you have Go 1.16+ installed
1. Verify that you have Go 1.16+ installed
```sh
$ go version
@ -8,14 +8,14 @@
If `go` is not installed, follow instructions on [the Go website](https://golang.org/doc/install).
1. Clone this repository
2. Clone this repository
```sh
$ git clone https://github.com/cli/cli.git gh-cli
$ cd gh-cli
```
2. Build and install
3. Build and install
#### Unix-like systems
```sh
@ -33,7 +33,7 @@
```
There is no install step available on Windows.
3. Run `gh version` to check if it worked.
4. Run `gh version` to check if it worked.
#### Windows
Run `bin\gh version` to check if it worked.

View file

@ -84,6 +84,15 @@ func CurrentBranch() (string, error) {
return "", fmt.Errorf("%sgit: %s", stderr.String(), err)
}
func listRemotesForPath(path string) ([]string, error) {
remoteCmd, err := GitCommand("-C", path, "remote", "-v")
if err != nil {
return nil, err
}
output, err := run.PrepareCmd(remoteCmd).Output()
return outputLines(output), err
}
func listRemotes() ([]string, error) {
remoteCmd, err := GitCommand("remote", "-v")
if err != nil {
@ -298,6 +307,19 @@ func CheckoutBranch(branch string) error {
return run.PrepareCmd(configCmd).Run()
}
// pull changes from remote branch without version history
func Pull(remote, branch string) error {
pullCmd, err := GitCommand("pull", "--ff-only", remote, branch)
if err != nil {
return err
}
pullCmd.Stdout = os.Stdout
pullCmd.Stderr = os.Stderr
pullCmd.Stdin = os.Stdin
return run.PrepareCmd(pullCmd).Run()
}
func parseCloneArgs(extraArgs []string) (args []string, target string) {
args = extraArgs
@ -366,6 +388,31 @@ func ToplevelDir() (string, error) {
}
// ToplevelDirFromPath returns the top-level given path of the current repository
func GetDirFromPath(p string) (string, error) {
showCmd, err := GitCommand("-C", p, "rev-parse", "--git-dir")
if err != nil {
return "", err
}
output, err := run.PrepareCmd(showCmd).Output()
return firstLine(output), err
}
func PathFromRepoRoot() string {
showCmd, err := GitCommand("rev-parse", "--show-prefix")
if err != nil {
return ""
}
output, err := run.PrepareCmd(showCmd).Output()
if err != nil {
return ""
}
if path := firstLine(output); path != "" {
return path[:len(path)-1]
}
return ""
}
func outputLines(output []byte) []string {
lines := strings.TrimSuffix(string(output), "\n")
return strings.Split(lines, "\n")

View file

@ -100,7 +100,7 @@ func Test_CurrentBranch(t *testing.T) {
result, err := CurrentBranch()
if err != nil {
t.Errorf("got unexpected error: %w", err)
t.Errorf("got unexpected error: %v", err)
}
if result != v.Expected {
t.Errorf("unexpected branch name: %s instead of %s", result, v.Expected)

View file

@ -35,16 +35,11 @@ func (r *Remote) String() string {
return r.Name
}
// Remotes gets the git remotes set for the current repo
func Remotes() (RemoteSet, error) {
list, err := listRemotes()
if err != nil {
return nil, err
}
remotes := parseRemotes(list)
func remotes(path string, remoteList []string) (RemoteSet, error) {
remotes := parseRemotes(remoteList)
// this is affected by SetRemoteResolution
remoteCmd, err := GitCommand("config", "--get-regexp", `^remote\..*\.gh-resolved$`)
remoteCmd, err := GitCommand("-C", path, "config", "--get-regexp", `^remote\..*\.gh-resolved$`)
if err != nil {
return nil, err
}
@ -70,6 +65,23 @@ func Remotes() (RemoteSet, error) {
return remotes, nil
}
func RemotesForPath(path string) (RemoteSet, error) {
list, err := listRemotesForPath(path)
if err != nil {
return nil, err
}
return remotes(path, list)
}
// Remotes gets the git remotes set for the current repo
func Remotes() (RemoteSet, error) {
list, err := listRemotes()
if err != nil {
return nil, err
}
return remotes(".", list)
}
func parseRemotes(gitRemotes []string) (remotes RemoteSet) {
for _, r := range gitRemotes {
match := remoteRE.FindStringSubmatch(r)
@ -140,6 +152,14 @@ func AddRemote(name, u string) (*Remote, error) {
}, nil
}
func UpdateRemoteURL(name, u string) error {
addCmd, err := GitCommand("remote", "set-url", name, u)
if err != nil {
return err
}
return run.PrepareCmd(addCmd).Run()
}
func SetRemoteResolution(name, resolution string) error {
addCmd, err := GitCommand("config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution)
if err != nil {

View file

@ -30,9 +30,8 @@ func (m SSHAliasMap) Translator() func(*url.URL) *url.URL {
if !ok {
return u
}
// FIXME: cleanup domain logic
if strings.EqualFold(u.Hostname(), "github.com") && strings.EqualFold(resolvedHost, "ssh.github.com") {
return u
if strings.EqualFold(resolvedHost, "ssh.github.com") {
resolvedHost = "github.com"
}
newURL, _ := url.Parse(u.String())
newURL.Host = resolvedHost

View file

@ -128,12 +128,14 @@ func Test_Translator(t *testing.T) {
m := SSHAliasMap{
"gh": "github.com",
"github.com": "ssh.github.com",
"my.gh.com": "ssh.github.com",
}
tr := m.Translator()
cases := [][]string{
{"ssh://gh/o/r", "ssh://github.com/o/r"},
{"ssh://github.com/o/r", "ssh://github.com/o/r"},
{"ssh://my.gh.com", "ssh://github.com"},
{"https://gh/o/r", "https://gh/o/r"},
}
for _, c := range cases {

View file

@ -14,6 +14,7 @@ func isSupportedProtocol(u string) bool {
strings.HasPrefix(u, "git+ssh:") ||
strings.HasPrefix(u, "git:") ||
strings.HasPrefix(u, "http:") ||
strings.HasPrefix(u, "git+https:") ||
strings.HasPrefix(u, "https:")
}
@ -43,6 +44,10 @@ func ParseURL(rawURL string) (u *url.URL, err error) {
u.Scheme = "ssh"
}
if u.Scheme == "git+https" {
u.Scheme = "https"
}
if u.Scheme != "ssh" {
return
}

View file

@ -28,11 +28,26 @@ func TestIsURL(t *testing.T) {
url: "git://example.com/owner/repo",
want: true,
},
{
name: "git with extension",
url: "git://example.com/owner/repo.git",
want: true,
},
{
name: "git+ssh",
url: "git+ssh://git@example.com/owner/repo.git",
want: true,
},
{
name: "https",
url: "https://example.com/owner/repo.git",
want: true,
},
{
name: "git+https",
url: "git+https://example.com/owner/repo.git",
want: true,
},
{
name: "no protocol",
url: "example.com/owner/repo",
@ -121,6 +136,16 @@ func TestParseURL(t *testing.T) {
Path: "/owner/repo.git",
},
},
{
name: "git+https",
url: "git+https://example.com/owner/repo.git",
want: url{
Scheme: "https",
User: "",
Host: "example.com",
Path: "/owner/repo.git",
},
},
{
name: "scp-like",
url: "git@example.com:owner/repo.git",

46
go.mod
View file

@ -3,38 +3,44 @@ module github.com/cli/cli/v2
go 1.16
require (
github.com/AlecAivazis/survey/v2 v2.3.1
github.com/AlecAivazis/survey/v2 v2.3.2
github.com/MakeNowJust/heredoc v1.0.0
github.com/briandowns/spinner v1.11.1
github.com/charmbracelet/glamour v0.3.0
github.com/briandowns/spinner v1.18.0
github.com/charmbracelet/glamour v0.4.0
github.com/cli/browser v1.1.0
github.com/cli/oauth v0.8.0
github.com/cli/oauth v0.9.0
github.com/cli/safeexec v1.0.0
github.com/cpuguy83/go-md2man/v2 v2.0.0
github.com/creack/pty v1.1.13
github.com/gabriel-vasile/mimetype v1.1.2
github.com/google/go-cmp v0.5.5
github.com/cli/shurcooL-graphql v0.0.1
github.com/cpuguy83/go-md2man/v2 v2.0.1
github.com/creack/pty v1.1.17
github.com/gabriel-vasile/mimetype v1.4.0
github.com/google/go-cmp v0.5.7
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/hashicorp/go-version v1.2.1
github.com/gorilla/websocket v1.4.2
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-version v1.3.0
github.com/henvic/httpretty v0.0.6
github.com/itchyny/gojq v0.12.4
github.com/itchyny/gojq v0.12.6
github.com/joho/godotenv v1.4.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-colorable v0.1.8
github.com/mattn/go-isatty v0.0.13
github.com/mattn/go-colorable v0.1.12
github.com/mattn/go-isatty v0.0.14
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5
github.com/muesli/termenv v0.8.1
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.9.0
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38
github.com/opentracing/opentracing-go v1.1.0
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f
github.com/spf13/cobra v1.2.1
github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a // indirect
github.com/sourcegraph/jsonrpc2 v0.1.0
github.com/spf13/cobra v1.3.0
github.com/spf13/pflag v1.0.5
github.com/stretchr/objx v0.1.1 // indirect
github.com/stretchr/testify v1.7.0
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d
golang.org/x/term v0.0.0-20210503060354-a79de5458b56
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
)
replace github.com/shurcooL/graphql => github.com/cli/shurcooL-graphql v0.0.0-20200707151639-0f7232a2bf7e
replace golang.org/x/crypto => github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03

399
go.sum
View file

@ -18,6 +18,15 @@ cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmW
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM=
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
@ -26,7 +35,7 @@ cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4g
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
@ -37,87 +46,116 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/AlecAivazis/survey/v2 v2.3.1 h1:lzkuHA60pER7L4eYL8qQJor4bUWlJe4V0gqAT19tdOA=
github.com/AlecAivazis/survey/v2 v2.3.1/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg=
github.com/AlecAivazis/survey/v2 v2.3.2 h1:TqTB+aDDCLYhf9/bD2TwSO8u8jDSmMUd2SUVO4gCnU8=
github.com/AlecAivazis/survey/v2 v2.3.2/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
github.com/alecthomas/chroma v0.8.2 h1:x3zkuE2lUk/RIekyAJ3XRqSCP4zwWDfcw/YJCuCAACg=
github.com/alecthomas/chroma v0.8.2/go.mod h1:sko8vR34/90zvl5QdcUdvzL3J8NKjAUx9va9jPuFNoM=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
github.com/alecthomas/kong v0.2.4/go.mod h1:kQOmtJgV+Lb4aj+I2LEn40cbtawdWJ9Y8QLq+lElKxE=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
github.com/briandowns/spinner v1.11.1 h1:OixPqDEcX3juo5AjQZAnFPbeUA0jvkp2qzB5gOZJ/L0=
github.com/briandowns/spinner v1.11.1/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ=
github.com/briandowns/spinner v1.18.0 h1:SJs0maNOs4FqhBwiJ3Gr7Z1D39/rukIVGQvpNZVHVcM=
github.com/briandowns/spinner v1.18.0/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/charmbracelet/glamour v0.3.0 h1:3H+ZrKlSg8s+WU6V7eF2eRVYt8lCueffbi7r2+ffGkc=
github.com/charmbracelet/glamour v0.3.0/go.mod h1:TzF0koPZhqq0YVBNL100cPHznAAjVj7fksX2RInwjGw=
github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/glamour v0.4.0 h1:scR+smyB7WdmrlIaff6IVlm48P48JaNM7JypM/VGl4k=
github.com/charmbracelet/glamour v0.4.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
github.com/cli/browser v1.1.0 h1:xOZBfkfY9L9vMBgqb1YwRirGu6QFaQ5dP/vXt5ENSOY=
github.com/cli/browser v1.1.0/go.mod h1:HKMQAt9t12kov91Mn7RfZxyJQQgWgyS/3SZswlZ5iTI=
github.com/cli/oauth v0.8.0 h1:YTFgPXSTvvDUFti3tR4o6q7Oll2SnQ9ztLwCAn4/IOA=
github.com/cli/oauth v0.8.0/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03 h1:3f4uHLfWx4/WlnMPXGai03eoWAI+oGHJwr+5OXfxCr8=
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
github.com/cli/oauth v0.9.0 h1:nxBC0Df4tUzMkqffAB+uZvisOwT3/N9FpkfdTDtafxc=
github.com/cli/oauth v0.9.0/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
github.com/cli/shurcooL-graphql v0.0.0-20200707151639-0f7232a2bf7e h1:aq/1jlmtZoS6nlSp3yLOTZQ50G+dzHdeRNENgE/iBew=
github.com/cli/shurcooL-graphql v0.0.0-20200707151639-0f7232a2bf7e/go.mod h1:it23pLwxmz6OyM6I5O0ATIXQS1S190Nas26L5Kahp4U=
github.com/cli/shurcooL-graphql v0.0.1 h1:/9J3t9O6p1B8zdBBtQighq5g7DQRItBwuwGh3SocsKM=
github.com/cli/shurcooL-graphql v0.0.1/go.mod h1:U7gCSuMZP/Qy7kbqkk5PrqXEeDgtfG5K+W+u8weorps=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.13 h1:rTPnd/xocYRjutMfqide2zle1u96upp1gm6eUHKi7us=
github.com/creack/pty v1.1.13/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk=
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/go-control-plane v0.10.1/go.mod h1:AY7fTTXNdv/aJ2O5jwpxAPOWUZ7hQAEvzN5Pf27BkQQ=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.1.2 h1:gaPnPcNor5aZSVCJVSGipcpbgMWiAAj9z182ocSGbHU=
github.com/gabriel-vasile/mimetype v1.1.2/go.mod h1:6CDPel/o/3/s4+bp6kIbsWATq8pmgOisOPG40CJa6To=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
@ -126,6 +164,7 @@ github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@ -144,6 +183,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@ -156,12 +196,15 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@ -173,63 +216,90 @@ github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
github.com/graph-gophers/graphql-go v0.0.0-20200622220639-c1d9693c95a6/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/consul/api v1.1.0/go.mod h1:VmuI/Lkw1nC05EYQWNKwWGbkg+FbDBtguAZLlVdkD9Q=
github.com/hashicorp/consul/sdk v0.1.1/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8=
github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
github.com/hashicorp/go-rootcerts v1.0.0/go.mod h1:K6zTfqpRlCUIjkwsN4Z+hiSfzSTQa6eBIzfwKfwNnHU=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI=
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw=
github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64=
github.com/hashicorp/mdns v1.0.0/go.mod h1:tL+uN++7HEJ6SQLQ2/p+z2pH24WQKWjBPkE0mNTz8vQ=
github.com/hashicorp/memberlist v0.1.3/go.mod h1:ajVTdAv/9Im8oMAAj5G31PhhMCZJV2pPBoIllUwCN7I=
github.com/hashicorp/serf v0.8.2/go.mod h1:6hOLApaqBFA1NXqRQAsxw9QxuDEvNxSQRwA/JwenrHc=
github.com/hashicorp/mdns v1.0.1/go.mod h1:4gW7WsVCke5TE7EPeYliwHlRUyBtfCwuFwuMg2DmyNY=
github.com/hashicorp/mdns v1.0.4/go.mod h1:mtBihi+LeNXGtG8L9dX59gAEa12BDtBQSp4v/YAJqrc=
github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs=
github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/itchyny/go-flags v1.5.0/go.mod h1:lenkYuCobuxLBAd/HGFE4LRoW8D3B6iXRQfWYJ+MNbA=
github.com/itchyny/gojq v0.12.4 h1:8zgOZWMejEWCLjbF/1mWY7hY7QEARm7dtuhC6Bp4R8o=
github.com/itchyny/gojq v0.12.4/go.mod h1:EQUSKgW/YaOxmXpAwGiowFDO4i2Rmtk5+9dFyeiymAg=
github.com/itchyny/gojq v0.12.6 h1:VjaFn59Em2wTxDNGcrRkDK9ZHMNa8IksOgL13sLL4d0=
github.com/itchyny/gojq v0.12.6/go.mod h1:ZHrkfu7A+RbZLy5J1/JKpS4poEqrzItSTGDItqsfP0A=
github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU=
github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.4 h1:5Myjjh3JY/NaAi4IsUbHADytDyl1VE1Y9PXDlL+P/VQ=
github.com/kr/pty v1.1.4/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@ -237,79 +307,108 @@ github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc87/1qhoTACD8w=
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microcosm-cc/bluemonday v1.0.6 h1:ZOvqHKtnx0fUpnbQm3m3zKFWE+DRC+XB1onh8JoEObE=
github.com/microcosm-cc/bluemonday v1.0.6/go.mod h1:HOT/6NaBlR0f9XlxD3zolN6Z3N8Lp4pvhp+jLS5ihnI=
github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8=
github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5 h1:T+Fc6qGlSfM+z0JPlp+n5rijvlg6C6JYFSNaqnCifDU=
github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/termenv v0.8.1 h1:9q230czSP3DHVpkaPDXGp0TOfAwyjyYwXlUCQxQSaBk=
github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 h1:0FrBxrkJ0hVembTb/e4EU5Ml6vLcOusAqymmYISg5Uo=
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38/go.mod h1:saF2fIVw4banK0H4+/EuqfFLpRnoy5S+ECwTOCcRcSU=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/opentracing/opentracing-go v1.1.0 h1:pWlfV3Bxv7k65HYwkikxat0+s3pV4bsqf19k25Ur8rU=
github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b h1:0/ecDXh/HTHRtSDSFnD2/Ta1yQ5J76ZspVY4u0/jGFk=
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a h1:KikTa6HtAK8cS1qjvUvvq4QO21QnwC+EfvB+OAuZ/ZU=
github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sourcegraph/jsonrpc2 v0.1.0 h1:ohJHjZ+PcaLxDUjqk2NC3tIGsVa5bXThe1ZheSXOjuk=
github.com/sourcegraph/jsonrpc2 v0.1.0/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw=
github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.3.0 h1:R7cSvGu+Vv+qX0gW5R/85dx2kmmJT5z5NM8ifdYjdn0=
github.com/spf13/cobra v1.3.0/go.mod h1:BrRVncBjOJa/eUcVVm9CE+oC6as8k+VYr4NY7WCi9V4=
github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
github.com/spf13/viper v1.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -322,18 +421,19 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.3/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.3.5 h1:dPmz1Snjq0kmkz159iL7S6WzdahUTHnHB5M56WFVifs=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs=
github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@ -341,19 +441,10 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
golang.org/x/crypto v0.0.0-20181029021203-45a5f77698d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -389,20 +480,22 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@ -424,9 +517,14 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -438,8 +536,13 @@ golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602 h1:0Ja1LBD+yisY6RWM/BH7TJVXWsSjs2VwBSmvSX4HdBc=
golang.org/x/oauth2 v0.0.0-20210402161424-2e8d93401602/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 h1:RerP+noqYHUQ8CMRcPlC2nvTa4dcBIjegkuWdcUDuqg=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -454,30 +557,35 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cO
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -490,15 +598,29 @@ golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b h1:qh4f65QIVFjq9eBURLEYWqaEXmOyqdUyiBSgaXWccWk=
golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
@ -508,8 +630,10 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -519,7 +643,6 @@ golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
@ -527,9 +650,9 @@ golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgw
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
@ -563,7 +686,11 @@ golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4f
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -590,7 +717,17 @@ google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34q
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
google.golang.org/api v0.44.0/go.mod h1:EBOGZqzyhtvMDoxwS97ctnh0zUmYY6CxqXsc1AvkYD8=
google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
google.golang.org/api v0.59.0/go.mod h1:sT2boj7M9YJxZzgeZqXogmhfmRWDtPzT31xkieUbuZU=
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
google.golang.org/api v0.62.0/go.mod h1:dKmwPCydfsad4qCH08MSdgWjfHOyfpd4VtDGgRFdavw=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -639,7 +776,29 @@ google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6D
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211008145708-270636b82663/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211028162531-8db9c33dc351/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@ -659,7 +818,15 @@ google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA5
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -671,15 +838,21 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.62.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -27,13 +27,11 @@ type iconfig interface {
Write() error
}
func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice string, additionalScopes []string) (string, error) {
func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice string, additionalScopes []string, isInteractive bool) (string, error) {
// TODO this probably shouldn't live in this package. It should probably be in a new package that
// depends on both iostreams and config.
stderr := IO.ErrOut
cs := IO.ColorScheme()
token, userLogin, err := authFlow(hostname, IO, notice, additionalScopes)
token, userLogin, err := authFlow(hostname, IO, notice, additionalScopes, isInteractive)
if err != nil {
return "", err
}
@ -47,19 +45,10 @@ func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice s
return "", err
}
err = cfg.Write()
if err != nil {
return "", err
}
fmt.Fprintf(stderr, "%s Authentication complete. %s to continue...\n",
cs.SuccessIcon(), cs.Bold("Press Enter"))
_ = waitForEnter(IO.In)
return token, nil
return token, cfg.Write()
}
func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string) (string, string, error) {
func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string, isInteractive bool) (string, string, error) {
w := IO.ErrOut
cs := IO.ColorScheme()
@ -80,7 +69,7 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
}
flow := &oauth.Flow{
Hostname: oauthHost,
Host: oauth.GitHubHost(ghinstance.HostPrefix(oauthHost)),
ClientID: oauthClientID,
ClientSecret: oauthClientSecret,
CallbackURI: callbackURI,
@ -90,7 +79,12 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
return nil
},
BrowseURL: func(url string) error {
fmt.Fprintf(w, "- %s to open %s in your browser... ", cs.Bold("Press Enter"), oauthHost)
if !isInteractive {
fmt.Fprintf(w, "%s to continue in your web browser: %s\n", cs.Bold("Open this URL"), url)
return nil
}
fmt.Fprintf(w, "%s to open %s in your browser... ", cs.Bold("Press Enter"), oauthHost)
_ = waitForEnter(IO.In)
// FIXME: read the browser from cmd Factory rather than recreating it
@ -103,7 +97,7 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
return nil
},
WriteSuccessHTML: func(w io.Writer) {
fmt.Fprintln(w, oauthSuccessPage)
fmt.Fprint(w, oauthSuccessPage)
},
HTTPClient: httpClient,
Stdin: IO.In,

View file

@ -0,0 +1,688 @@
package api
// For descriptions of service interfaces, see:
// - https://online.visualstudio.com/api/swagger (for visualstudio.com)
// - https://docs.github.com/en/rest/reference/repos (for api.github.com)
// - https://github.com/github/github/blob/master/app/api/codespaces.rb (for vscs_internal)
// TODO(adonovan): replace the last link with a public doc URL when available.
// TODO(adonovan): a possible reorganization would be to split this
// file into three internal packages, one per backend service, and to
// rename api.API to github.Client:
//
// - github.GetUser(github.Client)
// - github.GetRepository(Client)
// - github.ReadFile(Client, nwo, branch, path) // was GetCodespaceRepositoryContents
// - github.AuthorizedKeys(Client, user)
// - codespaces.Create(Client, user, repo, sku, branch, location)
// - codespaces.Delete(Client, user, token, name)
// - codespaces.Get(Client, token, owner, name)
// - codespaces.GetMachineTypes(Client, user, repo, branch, location)
// - codespaces.GetToken(Client, login, name)
// - codespaces.List(Client, user)
// - codespaces.Start(Client, token, codespace)
// - visualstudio.GetRegionLocation(http.Client) // no dependency on github
//
// This would make the meaning of each operation clearer.
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"reflect"
"regexp"
"strconv"
"strings"
"time"
"github.com/cli/cli/v2/api"
"github.com/opentracing/opentracing-go"
)
const (
githubServer = "https://github.com"
githubAPI = "https://api.github.com"
vscsAPI = "https://online.visualstudio.com"
)
// API is the interface to the codespace service.
type API struct {
client httpClient
vscsAPI string
githubAPI string
githubServer string
}
type httpClient interface {
Do(req *http.Request) (*http.Response, error)
}
// New creates a new API client connecting to the configured endpoints with the HTTP client.
func New(serverURL, apiURL, vscsURL string, httpClient httpClient) *API {
if serverURL == "" {
serverURL = githubServer
}
if apiURL == "" {
apiURL = githubAPI
}
if vscsURL == "" {
vscsURL = vscsAPI
}
return &API{
client: httpClient,
vscsAPI: strings.TrimSuffix(vscsURL, "/"),
githubAPI: strings.TrimSuffix(apiURL, "/"),
githubServer: strings.TrimSuffix(serverURL, "/"),
}
}
// User represents a GitHub user.
type User struct {
Login string `json:"login"`
}
// GetUser returns the user associated with the given token.
func (a *API) GetUser(ctx context.Context) (*User, error) {
req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/user", nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var response User
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
return &response, nil
}
// Repository represents a GitHub repository.
type Repository struct {
ID int `json:"id"`
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
}
// GetRepository returns the repository associated with the given owner and name.
func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error) {
req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+strings.ToLower(nwo), nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/repos/*")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var response Repository
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
return &response, nil
}
// Codespace represents a codespace.
type Codespace struct {
Name string `json:"name"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at"`
Owner User `json:"owner"`
Repository Repository `json:"repository"`
State string `json:"state"`
GitStatus CodespaceGitStatus `json:"git_status"`
Connection CodespaceConnection `json:"connection"`
}
type CodespaceGitStatus struct {
Ahead int `json:"ahead"`
Behind int `json:"behind"`
Ref string `json:"ref"`
HasUnpushedChanges bool `json:"has_unpushed_changes"`
HasUncommitedChanges bool `json:"has_uncommited_changes"`
}
const (
// CodespaceStateAvailable is the state for a running codespace environment.
CodespaceStateAvailable = "Available"
// CodespaceStateShutdown is the state for a shutdown codespace environment.
CodespaceStateShutdown = "Shutdown"
// CodespaceStateStarting is the state for a starting codespace environment.
CodespaceStateStarting = "Starting"
)
type CodespaceConnection struct {
SessionID string `json:"sessionId"`
SessionToken string `json:"sessionToken"`
RelayEndpoint string `json:"relayEndpoint"`
RelaySAS string `json:"relaySas"`
HostPublicKeys []string `json:"hostPublicKeys"`
}
// CodespaceFields is the list of exportable fields for a codespace.
var CodespaceFields = []string{
"name",
"owner",
"repository",
"state",
"gitStatus",
"createdAt",
"lastUsedAt",
}
func (c *Codespace) ExportData(fields []string) map[string]interface{} {
v := reflect.ValueOf(c).Elem()
data := map[string]interface{}{}
for _, f := range fields {
switch f {
case "owner":
data[f] = c.Owner.Login
case "repository":
data[f] = c.Repository.FullName
case "gitStatus":
data[f] = map[string]interface{}{
"ref": c.GitStatus.Ref,
"hasUnpushedChanges": c.GitStatus.HasUnpushedChanges,
"hasUncommitedChanges": c.GitStatus.HasUncommitedChanges,
}
default:
sf := v.FieldByNameFunc(func(s string) bool {
return strings.EqualFold(f, s)
})
data[f] = sf.Interface()
}
}
return data
}
// ListCodespaces returns a list of codespaces for the user. Pass a negative limit to request all pages from
// the API until all codespaces have been fetched.
func (a *API) ListCodespaces(ctx context.Context, limit int) (codespaces []*Codespace, err error) {
perPage := 100
if limit > 0 && limit < 100 {
perPage = limit
}
listURL := fmt.Sprintf("%s/user/codespaces?per_page=%d", a.githubAPI, perPage)
for {
req, err := http.NewRequest(http.MethodGet, listURL, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
var response struct {
Codespaces []*Codespace `json:"codespaces"`
}
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
nextURL := findNextPage(resp.Header.Get("Link"))
codespaces = append(codespaces, response.Codespaces...)
if nextURL == "" || (limit > 0 && len(codespaces) >= limit) {
break
}
if newPerPage := limit - len(codespaces); limit > 0 && newPerPage < 100 {
u, _ := url.Parse(nextURL)
q := u.Query()
q.Set("per_page", strconv.Itoa(newPerPage))
u.RawQuery = q.Encode()
listURL = u.String()
} else {
listURL = nextURL
}
}
return codespaces, nil
}
var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
func findNextPage(linkValue string) string {
for _, m := range linkRE.FindAllStringSubmatch(linkValue, -1) {
if len(m) > 2 && m[2] == "next" {
return m[1]
}
}
return ""
}
// GetCodespace returns the user codespace based on the provided name.
// If the codespace is not found, an error is returned.
// If includeConnection is true, it will return the connection information for the codespace.
func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeConnection bool) (*Codespace, error) {
req, err := http.NewRequest(
http.MethodGet,
a.githubAPI+"/user/codespaces/"+codespaceName,
nil,
)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
if includeConnection {
q := req.URL.Query()
q.Add("internal", "true")
q.Add("refresh", "true")
req.URL.RawQuery = q.Encode()
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var response Codespace
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
return &response, nil
}
// StartCodespace starts a codespace for the user.
// If the codespace is already running, the returned error from the API is ignored.
func (a *API) StartCodespace(ctx context.Context, codespaceName string) error {
req, err := http.NewRequest(
http.MethodPost,
a.githubAPI+"/user/codespaces/"+codespaceName+"/start",
nil,
)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*/start")
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusConflict {
// 409 means the codespace is already running which we can safely ignore
return nil
}
return api.HandleHTTPError(resp)
}
return nil
}
func (a *API) StopCodespace(ctx context.Context, codespaceName string) error {
req, err := http.NewRequest(
http.MethodPost,
a.githubAPI+"/user/codespaces/"+codespaceName+"/stop",
nil,
)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*/stop")
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return api.HandleHTTPError(resp)
}
return nil
}
type getCodespaceRegionLocationResponse struct {
Current string `json:"current"`
}
// GetCodespaceRegionLocation returns the closest codespace location for the user.
func (a *API) GetCodespaceRegionLocation(ctx context.Context) (string, error) {
req, err := http.NewRequest(http.MethodGet, a.vscsAPI+"/api/v1/locations", nil)
if err != nil {
return "", fmt.Errorf("error creating request: %w", err)
}
resp, err := a.do(ctx, req, req.URL.String())
if err != nil {
return "", fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response body: %w", err)
}
var response getCodespaceRegionLocationResponse
if err := json.Unmarshal(b, &response); err != nil {
return "", fmt.Errorf("error unmarshaling response: %w", err)
}
return response.Current, nil
}
type Machine struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
PrebuildAvailability string `json:"prebuild_availability"`
}
// GetCodespacesMachines returns the codespaces machines for the given repo, branch and location.
func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*Machine, error) {
reqURL := fmt.Sprintf("%s/repositories/%d/codespaces/machines", a.githubAPI, repoID)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
q := req.URL.Query()
q.Add("location", location)
q.Add("ref", branch)
req.URL.RawQuery = q.Encode()
a.setHeaders(req)
resp, err := a.do(ctx, req, "/repositories/*/codespaces/machines")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var response struct {
Machines []*Machine `json:"machines"`
}
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
return response.Machines, nil
}
// CreateCodespaceParams are the required parameters for provisioning a Codespace.
type CreateCodespaceParams struct {
RepositoryID int
IdleTimeoutMinutes int
Branch string
Machine string
Location string
}
// CreateCodespace creates a codespace with the given parameters and returns a non-nil error if it
// fails to create.
func (a *API) CreateCodespace(ctx context.Context, params *CreateCodespaceParams) (*Codespace, error) {
codespace, err := a.startCreate(ctx, params)
if err != errProvisioningInProgress {
return codespace, err
}
// errProvisioningInProgress indicates that codespace creation did not complete
// within the GitHub API RPC time limit (10s), so it continues asynchronously.
// We must poll the server to discover the outcome.
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
defer cancel()
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
codespace, err = a.GetCodespace(ctx, codespace.Name, false)
if err != nil {
return nil, fmt.Errorf("failed to get codespace: %w", err)
}
// we continue to poll until the codespace shows as provisioned
if codespace.State != CodespaceStateAvailable {
continue
}
return codespace, nil
}
}
}
type startCreateRequest struct {
RepositoryID int `json:"repository_id"`
IdleTimeoutMinutes int `json:"idle_timeout_minutes,omitempty"`
Ref string `json:"ref"`
Location string `json:"location"`
Machine string `json:"machine"`
}
var errProvisioningInProgress = errors.New("provisioning in progress")
// startCreate starts the creation of a codespace.
// It may return success or an error, or errProvisioningInProgress indicating that the operation
// did not complete before the GitHub API's time limit for RPCs (10s), in which case the caller
// must poll the server to learn the outcome.
func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*Codespace, error) {
if params == nil {
return nil, errors.New("startCreate missing parameters")
}
requestBody, err := json.Marshal(startCreateRequest{
RepositoryID: params.RepositoryID,
IdleTimeoutMinutes: params.IdleTimeoutMinutes,
Ref: params.Branch,
Location: params.Location,
Machine: params.Machine,
})
if err != nil {
return nil, fmt.Errorf("error marshaling request: %w", err)
}
req, err := http.NewRequest(http.MethodPost, a.githubAPI+"/user/codespaces", bytes.NewBuffer(requestBody))
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusAccepted {
return nil, errProvisioningInProgress // RPC finished before result of creation known
} else if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var response Codespace
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
return &response, nil
}
// DeleteCodespace deletes the given codespace.
func (a *API) DeleteCodespace(ctx context.Context, codespaceName string) error {
req, err := http.NewRequest(http.MethodDelete, a.githubAPI+"/user/codespaces/"+codespaceName, nil)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*")
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
return api.HandleHTTPError(resp)
}
return nil
}
type getCodespaceRepositoryContentsResponse struct {
Content string `json:"content"`
}
func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Codespace, path string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+codespace.Repository.FullName+"/contents/"+path, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
q := req.URL.Query()
q.Add("ref", codespace.GitStatus.Ref)
req.URL.RawQuery = q.Encode()
a.setHeaders(req)
resp, err := a.do(ctx, req, "/repos/*/contents/*")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
} else if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var response getCodespaceRepositoryContentsResponse
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
decoded, err := base64.StdEncoding.DecodeString(response.Content)
if err != nil {
return nil, fmt.Errorf("error decoding content: %w", err)
}
return decoded, nil
}
// AuthorizedKeys returns the public keys (in ~/.ssh/authorized_keys
// format) registered by the specified GitHub user.
func (a *API) AuthorizedKeys(ctx context.Context, user string) ([]byte, error) {
url := fmt.Sprintf("%s/%s.keys", a.githubServer, user)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := a.do(ctx, req, "/user.keys")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned %s", resp.Status)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
return b, nil
}
// do executes the given request and returns the response. It creates an
// opentracing span to track the length of the request.
func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http.Response, error) {
// TODO(adonovan): use NewRequestWithContext(ctx) and drop ctx parameter.
span, ctx := opentracing.StartSpanFromContext(ctx, spanName)
defer span.Finish()
req = req.WithContext(ctx)
return a.client.Do(req)
}
// setHeaders sets the required headers for the API.
func (a *API) setHeaders(req *http.Request) {
req.Header.Set("Accept", "application/vnd.github.v3+json")
}

View file

@ -0,0 +1,116 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
)
func generateCodespaceList(start int, end int) []*Codespace {
codespacesList := []*Codespace{}
for i := start; i < end; i++ {
codespacesList = append(codespacesList, &Codespace{
Name: fmt.Sprintf("codespace-%d", i),
})
}
return codespacesList
}
func createFakeListEndpointServer(t *testing.T, initalTotal int, finalTotal int) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user/codespaces" {
t.Fatal("Incorrect path")
}
page := 1
if r.URL.Query().Get("page") != "" {
page, _ = strconv.Atoi(r.URL.Query().Get("page"))
}
per_page := 0
if r.URL.Query().Get("per_page") != "" {
per_page, _ = strconv.Atoi(r.URL.Query().Get("per_page"))
}
response := struct {
Codespaces []*Codespace `json:"codespaces"`
TotalCount int `json:"total_count"`
}{
Codespaces: []*Codespace{},
TotalCount: finalTotal,
}
switch page {
case 1:
response.Codespaces = generateCodespaceList(0, per_page)
response.TotalCount = initalTotal
w.Header().Set("Link", fmt.Sprintf(`<http://%[1]s/user/codespaces?page=3&per_page=%[2]d>; rel="last", <http://%[1]s/user/codespaces?page=2&per_page=%[2]d>; rel="next"`, r.Host, per_page))
case 2:
response.Codespaces = generateCodespaceList(per_page, per_page*2)
response.TotalCount = finalTotal
w.Header().Set("Link", fmt.Sprintf(`<http://%s/user/codespaces?page=3&per_page=%d>; rel="next"`, r.Host, per_page))
case 3:
response.Codespaces = generateCodespaceList(per_page*2, per_page*3-per_page/2)
response.TotalCount = finalTotal
default:
t.Fatal("Should not check extra page")
}
data, _ := json.Marshal(response)
fmt.Fprint(w, string(data))
}))
}
func TestListCodespaces_limited(t *testing.T) {
svr := createFakeListEndpointServer(t, 200, 200)
defer svr.Close()
api := API{
githubAPI: svr.URL,
client: &http.Client{},
}
ctx := context.TODO()
codespaces, err := api.ListCodespaces(ctx, 200)
if err != nil {
t.Fatal(err)
}
if len(codespaces) != 200 {
t.Fatalf("expected 200 codespace, got %d", len(codespaces))
}
if codespaces[0].Name != "codespace-0" {
t.Fatalf("expected codespace-0, got %s", codespaces[0].Name)
}
if codespaces[199].Name != "codespace-199" {
t.Fatalf("expected codespace-199, got %s", codespaces[0].Name)
}
}
func TestListCodespaces_unlimited(t *testing.T) {
svr := createFakeListEndpointServer(t, 200, 200)
defer svr.Close()
api := API{
githubAPI: svr.URL,
client: &http.Client{},
}
ctx := context.TODO()
codespaces, err := api.ListCodespaces(ctx, -1)
if err != nil {
t.Fatal(err)
}
if len(codespaces) != 250 {
t.Fatalf("expected 250 codespace, got %d", len(codespaces))
}
if codespaces[0].Name != "codespace-0" {
t.Fatalf("expected codespace-0, got %s", codespaces[0].Name)
}
if codespaces[249].Name != "codespace-249" {
t.Fatalf("expected codespace-249, got %s", codespaces[0].Name)
}
}

View file

@ -0,0 +1,73 @@
package codespaces
import (
"context"
"errors"
"fmt"
"time"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/liveshare"
)
func connectionReady(codespace *api.Codespace) bool {
return codespace.Connection.SessionID != "" &&
codespace.Connection.SessionToken != "" &&
codespace.Connection.RelayEndpoint != "" &&
codespace.Connection.RelaySAS != "" &&
codespace.State == api.CodespaceStateAvailable
}
type apiClient interface {
GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error)
StartCodespace(ctx context.Context, name string) error
}
type progressIndicator interface {
StartProgressIndicatorWithLabel(s string)
StopProgressIndicator()
}
type logger interface {
Println(v ...interface{})
Printf(f string, v ...interface{})
}
// ConnectToLiveshare waits for a Codespace to become running,
// and connects to it using a Live Share session.
func ConnectToLiveshare(ctx context.Context, progress progressIndicator, sessionLogger logger, apiClient apiClient, codespace *api.Codespace) (sess *liveshare.Session, err error) {
if codespace.State != api.CodespaceStateAvailable {
progress.StartProgressIndicatorWithLabel("Starting codespace")
if err := apiClient.StartCodespace(ctx, codespace.Name); err != nil {
return nil, fmt.Errorf("error starting codespace: %w", err)
}
}
for retries := 0; !connectionReady(codespace); retries++ {
if retries > 1 {
time.Sleep(1 * time.Second)
}
if retries == 30 {
return nil, errors.New("timed out while waiting for the codespace to start")
}
codespace, err = apiClient.GetCodespace(ctx, codespace.Name, true)
if err != nil {
return nil, fmt.Errorf("error getting codespace: %w", err)
}
}
progress.StartProgressIndicatorWithLabel("Connecting to codespace")
defer progress.StopProgressIndicator()
return liveshare.Connect(ctx, liveshare.Options{
ClientName: "gh",
SessionID: codespace.Connection.SessionID,
SessionToken: codespace.Connection.SessionToken,
RelaySAS: codespace.Connection.RelaySAS,
RelayEndpoint: codespace.Connection.RelayEndpoint,
HostPublicKeys: codespace.Connection.HostPublicKeys,
Logger: sessionLogger,
})
}

121
internal/codespaces/ssh.go Normal file
View file

@ -0,0 +1,121 @@
package codespaces
import (
"context"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
)
type printer interface {
Printf(fmt string, v ...interface{})
}
// Shell runs an interactive secure shell over an existing
// port-forwarding session. It runs until the shell is terminated
// (including by cancellation of the context).
func Shell(ctx context.Context, p printer, sshArgs []string, port int, destination string, usingCustomPort bool) error {
cmd, connArgs, err := newSSHCommand(ctx, port, destination, sshArgs)
if err != nil {
return fmt.Errorf("failed to create ssh command: %w", err)
}
if usingCustomPort {
p.Printf("Connection Details: ssh %s %s", destination, connArgs)
}
return cmd.Run()
}
// Copy runs an scp command over the specified port. The arguments may
// include flags and non-flags, optionally separated by "--".
//
// Remote files indicated by a "remote:" prefix are resolved relative
// to the remote user's home directory, and are subject to shell expansion
// on the remote host; see https://lwn.net/Articles/835962/.
func Copy(ctx context.Context, scpArgs []string, port int, destination string) error {
// Beware: invalid syntax causes scp to exit 1 with
// no error message, so don't let that happen.
cmd := exec.CommandContext(ctx, "scp",
"-P", strconv.Itoa(port),
"-o", "NoHostAuthenticationForLocalhost=yes",
"-C", // compression
)
for _, arg := range scpArgs {
// Replace "remote:" prefix with (e.g.) "root@localhost:".
if rest := strings.TrimPrefix(arg, "remote:"); rest != arg {
arg = destination + ":" + rest
}
cmd.Args = append(cmd.Args, arg)
}
cmd.Stdin = nil
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run()
}
// NewRemoteCommand returns an exec.Cmd that will securely run a shell
// command on the remote machine.
func NewRemoteCommand(ctx context.Context, tunnelPort int, destination string, sshArgs ...string) (*exec.Cmd, error) {
cmd, _, err := newSSHCommand(ctx, tunnelPort, destination, sshArgs)
return cmd, err
}
// newSSHCommand populates an exec.Cmd to run a command (or if blank,
// an interactive shell) over ssh.
func newSSHCommand(ctx context.Context, port int, dst string, cmdArgs []string) (*exec.Cmd, []string, error) {
connArgs := []string{"-p", strconv.Itoa(port), "-o", "NoHostAuthenticationForLocalhost=yes"}
// The ssh command syntax is: ssh [flags] user@host command [args...]
// There is no way to specify the user@host destination as a flag.
// Unfortunately, that means we need to know which user-provided words are
// SSH flags and which are command arguments so that we can place
// them before or after the destination, and that means we need to know all
// the flags and their arities.
cmdArgs, command, err := parseSSHArgs(cmdArgs)
if err != nil {
return nil, nil, err
}
cmdArgs = append(cmdArgs, connArgs...)
cmdArgs = append(cmdArgs, "-C") // Compression
cmdArgs = append(cmdArgs, dst) // user@host
if command != nil {
cmdArgs = append(cmdArgs, command...)
}
cmd := exec.CommandContext(ctx, "ssh", cmdArgs...)
cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
return cmd, connArgs, nil
}
// parseSSHArgs parses SSH arguments into two distinct slices of flags and command.
// It returns an error if a unary flag is provided without an argument.
func parseSSHArgs(args []string) (cmdArgs, command []string, err error) {
for i := 0; i < len(args); i++ {
arg := args[i]
// if we've started parsing the command, set it to the rest of the args
if !strings.HasPrefix(arg, "-") {
command = args[i:]
break
}
cmdArgs = append(cmdArgs, arg)
if len(arg) == 2 && strings.Contains("bcDeFIiLlmOopRSWw", arg[1:2]) {
if i++; i == len(args) {
return nil, nil, fmt.Errorf("ssh flag: %s requires an argument", arg)
}
cmdArgs = append(cmdArgs, args[i])
}
}
return cmdArgs, command, nil
}

View file

@ -0,0 +1,105 @@
package codespaces
import (
"fmt"
"testing"
)
func TestParseSSHArgs(t *testing.T) {
type testCase struct {
Args []string
ParsedArgs []string
Command []string
Error string
}
testCases := []testCase{
{}, // empty test case
{
Args: []string{"-X", "-Y"},
ParsedArgs: []string{"-X", "-Y"},
Command: nil,
},
{
Args: []string{"-X", "-Y", "-o", "someoption=test"},
ParsedArgs: []string{"-X", "-Y", "-o", "someoption=test"},
Command: nil,
},
{
Args: []string{"-X", "-Y", "-o", "someoption=test", "somecommand"},
ParsedArgs: []string{"-X", "-Y", "-o", "someoption=test"},
Command: []string{"somecommand"},
},
{
Args: []string{"-X", "-Y", "-o", "someoption=test", "echo", "test"},
ParsedArgs: []string{"-X", "-Y", "-o", "someoption=test"},
Command: []string{"echo", "test"},
},
{
Args: []string{"somecommand"},
ParsedArgs: []string{},
Command: []string{"somecommand"},
},
{
Args: []string{"echo", "test"},
ParsedArgs: []string{},
Command: []string{"echo", "test"},
},
{
Args: []string{"-v", "echo", "hello", "world"},
ParsedArgs: []string{"-v"},
Command: []string{"echo", "hello", "world"},
},
{
Args: []string{"-L", "-l"},
ParsedArgs: []string{"-L", "-l"},
Command: nil,
},
{
Args: []string{"-v", "echo", "-n", "test"},
ParsedArgs: []string{"-v"},
Command: []string{"echo", "-n", "test"},
},
{
Args: []string{"-v", "echo", "-b", "test"},
ParsedArgs: []string{"-v"},
Command: []string{"echo", "-b", "test"},
},
{
Args: []string{"-b"},
ParsedArgs: nil,
Command: nil,
Error: "ssh flag: -b requires an argument",
},
}
for _, tcase := range testCases {
args, command, err := parseSSHArgs(tcase.Args)
if tcase.Error != "" {
if err == nil {
t.Errorf("expected error and got nil: %#v", tcase)
}
if err.Error() != tcase.Error {
t.Errorf("error does not match expected error, got: '%s', expected: '%s'", err.Error(), tcase.Error)
}
continue
}
if err != nil {
t.Errorf("unexpected error: %v on test case: %#v", err, tcase)
continue
}
argsStr, parsedArgsStr := fmt.Sprintf("%s", args), fmt.Sprintf("%s", tcase.ParsedArgs)
if argsStr != parsedArgsStr {
t.Errorf("args do not match parsed args. got: '%s', expected: '%s'", argsStr, parsedArgsStr)
}
commandStr, parsedCommandStr := fmt.Sprintf("%s", command), fmt.Sprintf("%s", tcase.Command)
if commandStr != parsedCommandStr {
t.Errorf("command does not match parsed command. got: '%s', expected: '%s'", commandStr, parsedCommandStr)
}
}
}

View file

@ -0,0 +1,126 @@
package codespaces
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/ioutil"
"log"
"net"
"strings"
"time"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/liveshare"
)
// PostCreateStateStatus is a string value representing the different statuses a state can have.
type PostCreateStateStatus string
func (p PostCreateStateStatus) String() string {
return strings.Title(string(p))
}
const (
PostCreateStateRunning PostCreateStateStatus = "running"
PostCreateStateSuccess PostCreateStateStatus = "succeeded"
PostCreateStateFailed PostCreateStateStatus = "failed"
)
// PostCreateState is a combination of a state and status value that is captured
// during codespace creation.
type PostCreateState struct {
Name string `json:"name"`
Status PostCreateStateStatus `json:"status"`
}
// PollPostCreateStates watches for state changes in a codespace,
// and calls the supplied poller for each batch of state changes.
// It runs until it encounters an error, including cancellation of the context.
func PollPostCreateStates(ctx context.Context, progress progressIndicator, apiClient apiClient, codespace *api.Codespace, poller func([]PostCreateState)) (err error) {
noopLogger := log.New(ioutil.Discard, "", 0)
session, err := ConnectToLiveshare(ctx, progress, noopLogger, apiClient, codespace)
if err != nil {
return fmt.Errorf("connect to codespace: %w", err)
}
defer func() {
if closeErr := session.Close(); err == nil {
err = closeErr
}
}()
// Ensure local port is listening before client (getPostCreateOutput) connects.
listen, err := net.Listen("tcp", "127.0.0.1:0") // arbitrary port
if err != nil {
return err
}
localPort := listen.Addr().(*net.TCPAddr).Port
progress.StartProgressIndicatorWithLabel("Fetching SSH Details")
defer progress.StopProgressIndicator()
remoteSSHServerPort, sshUser, err := session.StartSSHServer(ctx)
if err != nil {
return fmt.Errorf("error getting ssh server details: %w", err)
}
progress.StartProgressIndicatorWithLabel("Fetching status")
tunnelClosed := make(chan error, 1) // buffered to avoid sender stuckness
go func() {
fwd := liveshare.NewPortForwarder(session, "sshd", remoteSSHServerPort, false)
tunnelClosed <- fwd.ForwardToListener(ctx, listen) // error is non-nil
}()
t := time.NewTicker(1 * time.Second)
defer t.Stop()
for ticks := 0; ; ticks++ {
select {
case <-ctx.Done():
return ctx.Err()
case err := <-tunnelClosed:
return fmt.Errorf("connection failed: %w", err)
case <-t.C:
states, err := getPostCreateOutput(ctx, localPort, sshUser)
// There is an active progress indicator before the first tick
// to show that we are fetching statuses.
// Once the first tick happens, we stop the indicator and let
// the subsequent post create states manage their own progress.
if ticks == 0 {
progress.StopProgressIndicator()
}
if err != nil {
return fmt.Errorf("get post create output: %w", err)
}
poller(states)
}
}
}
func getPostCreateOutput(ctx context.Context, tunnelPort int, user string) ([]PostCreateState, error) {
cmd, err := NewRemoteCommand(
ctx, tunnelPort, fmt.Sprintf("%s@localhost", user),
"cat /workspaces/.codespaces/shared/postCreateOutput.json",
)
if err != nil {
return nil, fmt.Errorf("remote command: %w", err)
}
stdout := new(bytes.Buffer)
cmd.Stdout = stdout
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("run command: %w", err)
}
var output struct {
Steps []PostCreateState `json:"steps"`
}
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
return nil, fmt.Errorf("unmarshal output: %w", err)
}
return output.Steps, nil
}

View file

@ -49,7 +49,7 @@ func ConfigDir() string {
}
// State path precedence
// 1. XDG_CONFIG_HOME
// 1. XDG_STATE_HOME
// 2. LocalAppData (windows only)
// 3. HOME
func StateDir() string {

View file

@ -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)
}

View file

@ -6,7 +6,7 @@ import (
"gopkg.in/yaml.v3"
)
// This type implements a low-level get/set config that is backed by an in-memory tree of Yaml
// This type implements a low-level get/set config that is backed by an in-memory tree of yaml
// nodes. It allows us to interact with a yaml-based config programmatically, preserving any
// comments that were present when the yaml was parsed.
type ConfigMap struct {
@ -37,41 +37,41 @@ func (cm *ConfigMap) GetStringValue(key string) (string, error) {
func (cm *ConfigMap) SetStringValue(key, value string) error {
entry, err := cm.FindEntry(key)
if err == nil {
entry.ValueNode.Value = value
return nil
}
var notFound *NotFoundError
valueNode := entry.ValueNode
if err != nil && errors.As(err, &notFound) {
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: key,
}
valueNode = &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: "",
}
cm.Root.Content = append(cm.Root.Content, keyNode, valueNode)
} else if err != nil {
if err != nil && !errors.As(err, &notFound) {
return err
}
valueNode.Value = value
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: key,
}
valueNode := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: value,
}
cm.Root.Content = append(cm.Root.Content, keyNode, valueNode)
return nil
}
func (cm *ConfigMap) FindEntry(key string) (ce *ConfigEntry, err error) {
err = nil
func (cm *ConfigMap) FindEntry(key string) (*ConfigEntry, error) {
ce := &ConfigEntry{}
ce = &ConfigEntry{}
if cm.Empty() {
return ce, &NotFoundError{errors.New("not found")}
}
// Content slice goes [key1, value1, key2, value2, ...]
// Content slice goes [key1, value1, key2, value2, ...].
topLevelPairs := cm.Root.Content
for i, v := range topLevelPairs {
// Skip every other slice item since we only want to check against keys
// Skip every other slice item since we only want to check against keys.
if i%2 != 0 {
continue
}
@ -81,7 +81,7 @@ func (cm *ConfigMap) FindEntry(key string) (ce *ConfigEntry, err error) {
if i+1 < len(topLevelPairs) {
ce.ValueNode = topLevelPairs[i+1]
}
return
return ce, nil
}
}
@ -89,14 +89,23 @@ func (cm *ConfigMap) FindEntry(key string) (ce *ConfigEntry, err error) {
}
func (cm *ConfigMap) RemoveEntry(key string) {
if cm.Empty() {
return
}
newContent := []*yaml.Node{}
content := cm.Root.Content
for i := 0; i < len(content); i++ {
if content[i].Value == key {
i++ // skip the next node which is this key's value
var skipNext bool
for i, v := range cm.Root.Content {
if skipNext {
skipNext = false
continue
}
if i%2 != 0 || v.Value != key {
newContent = append(newContent, v)
} else {
newContent = append(newContent, content[i])
// Don't append current node and skip the next which is this key's value.
skipNext = true
}
}

View file

@ -1,7 +1,6 @@
package config
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
@ -46,12 +45,135 @@ func TestFindEntry(t *testing.T) {
return
}
assert.NoError(t, err)
fmt.Println(out)
assert.Equal(t, tt.output, out.ValueNode.Value)
})
}
}
func TestEmpty(t *testing.T) {
cm := ConfigMap{}
assert.Equal(t, true, cm.Empty())
cm.Root = &yaml.Node{
Content: []*yaml.Node{
{
Value: "test",
},
},
}
assert.Equal(t, false, cm.Empty())
}
func TestGetStringValue(t *testing.T) {
tests := []struct {
name string
key string
wantValue string
wantErr bool
}{
{
name: "get key",
key: "valid",
wantValue: "present",
},
{
name: "get key that is not present",
key: "invalid",
wantErr: true,
},
{
name: "get key that has same content as a value",
key: "same",
wantValue: "logical",
},
}
for _, tt := range tests {
cm := ConfigMap{Root: testYaml()}
t.Run(tt.name, func(t *testing.T) {
val, err := cm.GetStringValue(tt.key)
if tt.wantErr {
assert.EqualError(t, err, "not found")
return
}
assert.Equal(t, tt.wantValue, val)
})
}
}
func TestSetStringValue(t *testing.T) {
tests := []struct {
name string
key string
value string
}{
{
name: "set key that is not present",
key: "notPresent",
value: "test1",
},
{
name: "set key that is present",
key: "erroneous",
value: "test2",
},
{
name: "set key that is blank",
key: "blank",
value: "test3",
},
{
name: "set key that has same content as a value",
key: "present",
value: "test4",
},
}
for _, tt := range tests {
cm := ConfigMap{Root: testYaml()}
t.Run(tt.name, func(t *testing.T) {
err := cm.SetStringValue(tt.key, tt.value)
assert.NoError(t, err)
val, err := cm.GetStringValue(tt.key)
assert.NoError(t, err)
assert.Equal(t, tt.value, val)
})
}
}
func TestRemoveEntry(t *testing.T) {
tests := []struct {
name string
key string
wantLength int
}{
{
name: "remove key",
key: "erroneous",
wantLength: 6,
},
{
name: "remove key that is not present",
key: "invalid",
wantLength: 8,
},
{
name: "remove key that has same content as a value",
key: "same",
wantLength: 6,
},
}
for _, tt := range tests {
cm := ConfigMap{Root: testYaml()}
t.Run(tt.name, func(t *testing.T) {
cm.RemoveEntry(tt.key)
assert.Equal(t, tt.wantLength, len(cm.Root.Content))
_, err := cm.FindEntry(tt.key)
assert.EqualError(t, err, "not found")
})
}
}
func testYaml() *yaml.Node {
var root yaml.Node
var data = `

View file

@ -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)

View file

@ -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)

View file

@ -3,6 +3,7 @@ package config
import (
"fmt"
"os"
"strconv"
"github.com/cli/cli/v2/internal/ghinstance"
)
@ -13,6 +14,7 @@ const (
GITHUB_TOKEN = "GITHUB_TOKEN"
GH_ENTERPRISE_TOKEN = "GH_ENTERPRISE_TOKEN"
GITHUB_ENTERPRISE_TOKEN = "GITHUB_ENTERPRISE_TOKEN"
CODESPACES = "CODESPACES"
)
type ReadOnlyEnvError struct {
@ -74,6 +76,24 @@ func (c *envConfig) GetWithSource(hostname, key string) (string, string, error)
return c.Config.GetWithSource(hostname, key)
}
func (c *envConfig) GetOrDefault(hostname, key string) (val string, err error) {
val, _, err = c.GetOrDefaultWithSource(hostname, key)
return
}
func (c *envConfig) GetOrDefaultWithSource(hostname, key string) (val string, src string, err error) {
val, src, err = c.GetWithSource(hostname, key)
if err == nil && val == "" {
val = c.Default(key)
}
return
}
func (c *envConfig) Default(key string) string {
return c.Config.Default(key)
}
func (c *envConfig) CheckWriteable(hostname, key string) error {
if hostname != "" && key == "oauth_token" {
if token, env := AuthTokenFromEnv(hostname); token != "" {
@ -90,7 +110,15 @@ func AuthTokenFromEnv(hostname string) (string, string) {
return token, GH_ENTERPRISE_TOKEN
}
return os.Getenv(GITHUB_ENTERPRISE_TOKEN), GITHUB_ENTERPRISE_TOKEN
if token := os.Getenv(GITHUB_ENTERPRISE_TOKEN); token != "" {
return token, GITHUB_ENTERPRISE_TOKEN
}
if isCodespaces, _ := strconv.ParseBool(os.Getenv(CODESPACES)); isCodespaces {
return os.Getenv(GITHUB_TOKEN), GITHUB_TOKEN
}
return "", ""
}
if token := os.Getenv(GH_TOKEN); token != "" {

View file

@ -8,6 +8,18 @@ import (
"github.com/stretchr/testify/assert"
)
func setenv(t *testing.T, key, newValue string) {
oldValue, hasValue := os.LookupEnv(key)
os.Setenv(key, newValue)
t.Cleanup(func() {
if hasValue {
os.Setenv(key, oldValue)
} else {
os.Unsetenv(key)
}
})
}
func TestInheritEnv(t *testing.T) {
orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN")
orig_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN")
@ -36,6 +48,7 @@ func TestInheritEnv(t *testing.T) {
GITHUB_ENTERPRISE_TOKEN string
GH_TOKEN string
GH_ENTERPRISE_TOKEN string
CODESPACES string
hostname string
wants wants
}{
@ -98,6 +111,19 @@ func TestInheritEnv(t *testing.T) {
writeable: true,
},
},
{
name: "GITHUB_TOKEN allowed in Codespaces",
baseConfig: ``,
GITHUB_TOKEN: "OTOKEN",
hostname: "example.org",
CODESPACES: "true",
wants: wants{
hosts: []string{"github.com"},
token: "OTOKEN",
source: "GITHUB_TOKEN",
writeable: false,
},
},
{
name: "GITHUB_ENTERPRISE_TOKEN over blank config",
baseConfig: ``,
@ -262,11 +288,12 @@ func TestInheritEnv(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
os.Setenv("GITHUB_TOKEN", tt.GITHUB_TOKEN)
os.Setenv("GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN)
os.Setenv("GH_TOKEN", tt.GH_TOKEN)
os.Setenv("GH_ENTERPRISE_TOKEN", tt.GH_ENTERPRISE_TOKEN)
os.Setenv("AppData", "")
setenv(t, "GITHUB_TOKEN", tt.GITHUB_TOKEN)
setenv(t, "GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN)
setenv(t, "GH_TOKEN", tt.GH_TOKEN)
setenv(t, "GH_ENTERPRISE_TOKEN", tt.GH_ENTERPRISE_TOKEN)
setenv(t, "AppData", "")
setenv(t, "CODESPACES", tt.CODESPACES)
baseCfg := NewFromString(tt.baseConfig)
cfg := InheritEnv(baseCfg)

View file

@ -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)

View file

@ -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

View file

@ -79,12 +79,14 @@ var dummyCmd = &cobra.Command{
}
func checkStringContains(t *testing.T, got, expected string) {
t.Helper()
if !strings.Contains(got, expected) {
t.Errorf("Expected to contain: \n %v\nGot:\n %v\n", expected, got)
}
}
func checkStringOmits(t *testing.T, got, expected string) {
t.Helper()
if strings.Contains(got, expected) {
t.Errorf("Expected to not contain: \n %v\nGot: %v", expected, got)
}

View file

@ -6,7 +6,6 @@ import (
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
@ -21,9 +20,8 @@ import (
// correctly if your command names have `-` in them. If you have `cmd` with two
// subcmds, `sub` and `sub-third`, and `sub` has a subcommand called `third`
// it is undefined which help output will be in the file `cmd-sub-third.1`.
func GenManTree(cmd *cobra.Command, header *GenManHeader, dir string) error {
func GenManTree(cmd *cobra.Command, dir string) error {
return GenManTreeFromOpts(cmd, GenManTreeOptions{
Header: header,
Path: dir,
CommandSeparator: "-",
})
@ -32,10 +30,6 @@ func GenManTree(cmd *cobra.Command, header *GenManHeader, dir string) error {
// GenManTreeFromOpts generates a man page for the command and all descendants.
// The pages are written to the opts.Path directory.
func GenManTreeFromOpts(cmd *cobra.Command, opts GenManTreeOptions) error {
header := opts.Header
if header == nil {
header = &GenManHeader{}
}
for _, c := range cmd.Commands() {
if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() {
continue
@ -44,11 +38,8 @@ func GenManTreeFromOpts(cmd *cobra.Command, opts GenManTreeOptions) error {
return err
}
}
section := "1"
if header.Section != "" {
section = header.Section
}
section := "1"
separator := "_"
if opts.CommandSeparator != "" {
separator = opts.CommandSeparator
@ -61,14 +52,21 @@ func GenManTreeFromOpts(cmd *cobra.Command, opts GenManTreeOptions) error {
}
defer f.Close()
headerCopy := *header
return GenMan(cmd, &headerCopy, f)
var versionString string
if v := os.Getenv("GH_VERSION"); v != "" {
versionString = "GitHub CLI " + v
}
return GenMan(cmd, &GenManHeader{
Section: section,
Source: versionString,
Manual: "GitHub CLI manual",
}, f)
}
// GenManTreeOptions is the options for generating the man pages.
// Used only in GenManTreeFromOpts.
type GenManTreeOptions struct {
Header *GenManHeader
Path string
CommandSeparator string
}
@ -80,7 +78,6 @@ type GenManHeader struct {
Title string
Section string
Date *time.Time
date string
Source string
Manual string
}
@ -88,14 +85,12 @@ type GenManHeader struct {
// GenMan will generate a man page for the given command and write it to
// w. The header argument may be nil, however obviously w may not.
func GenMan(cmd *cobra.Command, header *GenManHeader, w io.Writer) error {
if header == nil {
header = &GenManHeader{}
}
if err := fillHeader(header, cmd.CommandPath()); err != nil {
return err
}
b := genMan(cmd, header)
_, err := w.Write(md2man.Render(b))
return err
}
@ -118,51 +113,40 @@ func fillHeader(header *GenManHeader, name string) error {
}
header.Date = &now
}
header.date = (*header.Date).Format("Jan 2006")
return nil
}
func manPreamble(buf *bytes.Buffer, header *GenManHeader, cmd *cobra.Command, dashedName string) {
description := cmd.Long
if len(description) == 0 {
description = cmd.Short
}
buf.WriteString(fmt.Sprintf(`%% "%s" "%s" "%s" "%s" "%s"
# NAME
`, header.Title, header.Section, header.date, header.Source, header.Manual))
`, header.Title, header.Section, header.Date.Format("Jan 2006"), header.Source, header.Manual))
buf.WriteString(fmt.Sprintf("%s \\- %s\n\n", dashedName, cmd.Short))
buf.WriteString("# SYNOPSIS\n")
buf.WriteString(fmt.Sprintf("**%s**\n\n", cmd.UseLine()))
buf.WriteString("# DESCRIPTION\n")
buf.WriteString(description + "\n\n")
buf.WriteString(fmt.Sprintf("`%s`\n\n", cmd.UseLine()))
if cmd.Long != "" && cmd.Long != cmd.Short {
buf.WriteString("# DESCRIPTION\n")
buf.WriteString(cmd.Long + "\n\n")
}
}
func manPrintFlags(buf *bytes.Buffer, flags *pflag.FlagSet) {
flags.VisitAll(func(flag *pflag.Flag) {
if len(flag.Deprecated) > 0 || flag.Hidden {
if len(flag.Deprecated) > 0 || flag.Hidden || flag.Name == "help" {
return
}
format := ""
varname, usage := pflag.UnquoteUsage(flag)
if len(flag.Shorthand) > 0 && len(flag.ShorthandDeprecated) == 0 {
format = fmt.Sprintf("**-%s**, **--%s**", flag.Shorthand, flag.Name)
buf.WriteString(fmt.Sprintf("`-%s`, `--%s`", flag.Shorthand, flag.Name))
} else {
format = fmt.Sprintf("**--%s**", flag.Name)
buf.WriteString(fmt.Sprintf("`--%s`", flag.Name))
}
if len(flag.NoOptDefVal) > 0 {
format += "["
}
if flag.Value.Type() == "string" {
// put quotes on the value
format += "=%q"
if varname == "" {
buf.WriteString("\n")
} else {
format += "=%s"
buf.WriteString(fmt.Sprintf(" `<%s>`\n", varname))
}
if len(flag.NoOptDefVal) > 0 {
format += "]"
}
format += "\n\t%s\n\n"
buf.WriteString(fmt.Sprintf(format, flag.DefValue, flag.Usage))
buf.WriteString(fmt.Sprintf(": %s\n\n", usage))
})
}
@ -174,7 +158,7 @@ func manPrintOptions(buf *bytes.Buffer, command *cobra.Command) {
buf.WriteString("\n")
}
flags = command.InheritedFlags()
if flags.HasAvailableFlags() {
if hasNonHelpFlags(flags) {
buf.WriteString("# OPTIONS INHERITED FROM PARENT COMMANDS\n")
manPrintFlags(buf, flags)
buf.WriteString("\n")
@ -191,52 +175,28 @@ func genMan(cmd *cobra.Command, header *GenManHeader) []byte {
buf := new(bytes.Buffer)
manPreamble(buf, header, cmd, dashCommandName)
for _, g := range subcommandGroups(cmd) {
if len(g.Commands) == 0 {
continue
}
fmt.Fprintf(buf, "# %s\n", strings.ToUpper(g.Name))
for _, subcmd := range g.Commands {
fmt.Fprintf(buf, "`%s`\n: %s\n\n", manLink(subcmd), subcmd.Short)
}
}
manPrintOptions(buf, cmd)
if len(cmd.Example) > 0 {
buf.WriteString("# EXAMPLE\n")
buf.WriteString(fmt.Sprintf("```\n%s\n```\n", cmd.Example))
}
if hasSeeAlso(cmd) {
if cmd.HasParent() {
buf.WriteString("# SEE ALSO\n")
seealsos := make([]string, 0)
if cmd.HasParent() {
parentPath := cmd.Parent().CommandPath()
dashParentPath := strings.Replace(parentPath, " ", "-", -1)
seealso := fmt.Sprintf("**%s(%s)**", dashParentPath, header.Section)
seealsos = append(seealsos, seealso)
}
children := cmd.Commands()
sort.Sort(byName(children))
for _, c := range children {
if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() {
continue
}
seealso := fmt.Sprintf("**%s-%s(%s)**", dashCommandName, c.Name(), header.Section)
seealsos = append(seealsos, seealso)
}
buf.WriteString(strings.Join(seealsos, ", ") + "\n")
buf.WriteString(fmt.Sprintf("`%s`\n", manLink(cmd.Parent())))
}
return buf.Bytes()
}
// Test to see if we have a reason to print See Also information in docs
// Basically this is a test for a parent command or a subcommand which is
// both not deprecated and not the autogenerated help command.
func hasSeeAlso(cmd *cobra.Command) bool {
if cmd.HasParent() {
return true
}
for _, c := range cmd.Commands() {
if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() {
continue
}
return true
}
return false
func manLink(cmd *cobra.Command) string {
p := cmd.CommandPath()
return fmt.Sprintf("%s(%d)", strings.Replace(p, " ", "-", -1), 1)
}
type byName []*cobra.Command
func (s byName) Len() int { return len(s) }
func (s byName) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func (s byName) Less(i, j int) bool { return s[i].Name() < s[j].Name() }

View file

@ -20,7 +20,7 @@ func translate(in string) string {
func TestGenManDoc(t *testing.T) {
header := &GenManHeader{
Title: "Project",
Section: "2",
Section: "1",
}
// We generate on a subcommand so we have both subcommands and parents
@ -49,7 +49,7 @@ func TestGenManDoc(t *testing.T) {
func TestGenManNoHiddenParents(t *testing.T) {
header := &GenManHeader{
Title: "Project",
Section: "2",
Section: "1",
}
// We generate on a subcommand so we have both subcommands and parents
@ -94,15 +94,8 @@ func TestGenManSeeAlso(t *testing.T) {
t.Fatal(err)
}
scanner := bufio.NewScanner(buf)
if err := assertLineFound(scanner, ".SH SEE ALSO"); err != nil {
t.Fatalf("Couldn't find SEE ALSO section header: %v", err)
}
if err := assertNextLineEquals(scanner, ".PP"); err != nil {
t.Fatalf("First line after SEE ALSO wasn't break-indent: %v", err)
}
if err := assertNextLineEquals(scanner, `\fBroot\-bbb(1)\fP, \fBroot\-ccc(1)\fP`); err != nil {
t.Fatalf("Second line after SEE ALSO wasn't correct: %v", err)
if err := assertLineFound(scanner, ".SH SEE ALSO"); err == nil {
t.Fatalf("Did not expect SEE ALSO section header")
}
}
@ -115,31 +108,26 @@ func TestManPrintFlagsHidesShortDeprecated(t *testing.T) {
manPrintFlags(buf, c.Flags())
got := buf.String()
expected := "**--foo**=\"default\"\n\tFoo flag\n\n"
expected := "`--foo` `<string>`\n: Foo flag\n\n"
if got != expected {
t.Errorf("Expected %v, got %v", expected, got)
t.Errorf("Expected %q, got %q", expected, got)
}
}
func TestGenManTree(t *testing.T) {
c := &cobra.Command{Use: "do [OPTIONS] arg1 arg2"}
header := &GenManHeader{Section: "2"}
tmpdir, err := ioutil.TempDir("", "test-gen-man-tree")
if err != nil {
t.Fatalf("Failed to create tmpdir: %s", err.Error())
}
defer os.RemoveAll(tmpdir)
if err := GenManTree(c, header, tmpdir); err != nil {
if err := GenManTree(c, tmpdir); err != nil {
t.Fatalf("GenManTree failed: %s", err.Error())
}
if _, err := os.Stat(filepath.Join(tmpdir, "do.2")); err != nil {
t.Fatalf("Expected file 'do.2' to exist")
}
if header.Title != "" {
t.Fatalf("Expected header.Title to be unmodified")
if _, err := os.Stat(filepath.Join(tmpdir, "do.1")); err != nil {
t.Fatalf("Expected file 'do.1' to exist")
}
}
@ -158,22 +146,6 @@ func assertLineFound(scanner *bufio.Scanner, expectedLine string) error {
return fmt.Errorf("hit EOF before finding %v", expectedLine)
}
func assertNextLineEquals(scanner *bufio.Scanner, expectedLine string) error {
if scanner.Scan() {
line := scanner.Text()
if line == expectedLine {
return nil
}
return fmt.Errorf("got %v, not %v", line, expectedLine)
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scan failed: %v", err)
}
return fmt.Errorf("hit EOF before finding %v", expectedLine)
}
func BenchmarkGenManToFile(b *testing.B) {
file, err := ioutil.TempFile(b.TempDir(), "")
if err != nil {

View file

@ -1,35 +1,83 @@
package docs
import (
"bytes"
"fmt"
"html/template"
"io"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func printOptions(buf *bytes.Buffer, cmd *cobra.Command, name string) error {
func printOptions(w io.Writer, cmd *cobra.Command) error {
flags := cmd.NonInheritedFlags()
flags.SetOutput(buf)
flags.SetOutput(w)
if flags.HasAvailableFlags() {
buf.WriteString("### Options\n\n```\n")
flags.PrintDefaults()
buf.WriteString("```\n\n")
fmt.Fprint(w, "### Options\n\n")
if err := printFlagsHTML(w, flags); err != nil {
return err
}
fmt.Fprint(w, "\n\n")
}
parentFlags := cmd.InheritedFlags()
parentFlags.SetOutput(buf)
if parentFlags.HasAvailableFlags() {
buf.WriteString("### Options inherited from parent commands\n\n```\n")
parentFlags.PrintDefaults()
buf.WriteString("```\n\n")
parentFlags.SetOutput(w)
if hasNonHelpFlags(parentFlags) {
fmt.Fprint(w, "### Options inherited from parent commands\n\n")
if err := printFlagsHTML(w, parentFlags); err != nil {
return err
}
fmt.Fprint(w, "\n\n")
}
return nil
}
func hasNonHelpFlags(fs *pflag.FlagSet) (found bool) {
fs.VisitAll(func(f *pflag.Flag) {
if !f.Hidden && f.Name != "help" {
found = true
}
})
return
}
type flagView struct {
Name string
Varname string
Shorthand string
Usage string
}
var flagsTemplate = `
<dl class="flags">{{ range . }}
<dt>{{ if .Shorthand }}<code>-{{.Shorthand}}</code>, {{ end -}}
<code>--{{.Name}}{{ if .Varname }} &lt;{{.Varname}}&gt;{{ end }}</code></dt>
<dd>{{.Usage}}</dd>
{{ end }}</dl>
`
var tpl = template.Must(template.New("flags").Parse(flagsTemplate))
func printFlagsHTML(w io.Writer, fs *pflag.FlagSet) error {
var flags []flagView
fs.VisitAll(func(f *pflag.Flag) {
if f.Hidden || f.Name == "help" {
return
}
varname, usage := pflag.UnquoteUsage(f)
flags = append(flags, flagView{
Name: f.Name,
Varname: varname,
Shorthand: f.Shorthand,
Usage: usage,
})
})
return tpl.Execute(w, flags)
}
// GenMarkdown creates markdown output.
func GenMarkdown(cmd *cobra.Command, w io.Writer) error {
return GenMarkdownCustom(cmd, w, func(s string) string { return s })
@ -37,33 +85,97 @@ func GenMarkdown(cmd *cobra.Command, w io.Writer) error {
// GenMarkdownCustom creates custom markdown output.
func GenMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string) string) error {
cmd.InitDefaultHelpCmd()
cmd.InitDefaultHelpFlag()
fmt.Fprintf(w, "## %s\n\n", cmd.CommandPath())
buf := new(bytes.Buffer)
name := cmd.CommandPath()
buf.WriteString("## " + name + "\n\n")
buf.WriteString(cmd.Short + "\n\n")
if len(cmd.Long) > 0 {
buf.WriteString("### Synopsis\n\n")
buf.WriteString(cmd.Long + "\n\n")
hasLong := cmd.Long != ""
if !hasLong {
fmt.Fprintf(w, "%s\n\n", cmd.Short)
}
if cmd.Runnable() {
fmt.Fprintf(w, "```\n%s\n```\n\n", cmd.UseLine())
}
if hasLong {
fmt.Fprintf(w, "%s\n\n", cmd.Long)
}
if cmd.Runnable() {
buf.WriteString(fmt.Sprintf("```\n%s\n```\n\n", cmd.UseLine()))
for _, g := range subcommandGroups(cmd) {
if len(g.Commands) == 0 {
continue
}
fmt.Fprintf(w, "### %s\n\n", g.Name)
for _, subcmd := range g.Commands {
fmt.Fprintf(w, "* [%s](%s)\n", subcmd.CommandPath(), linkHandler(cmdManualPath(subcmd)))
}
fmt.Fprint(w, "\n\n")
}
if err := printOptions(w, cmd); err != nil {
return err
}
if len(cmd.Example) > 0 {
buf.WriteString("### Examples\n\n")
buf.WriteString(fmt.Sprintf("```\n%s\n```\n\n", cmd.Example))
fmt.Fprint(w, "### Examples\n\n{% highlight bash %}{% raw %}\n")
fmt.Fprint(w, cmd.Example)
fmt.Fprint(w, "{% endraw %}{% endhighlight %}\n\n")
}
if err := printOptions(buf, cmd, name); err != nil {
return err
if cmd.HasParent() {
p := cmd.Parent()
fmt.Fprint(w, "### See also\n\n")
fmt.Fprintf(w, "* [%s](%s)\n", p.CommandPath(), linkHandler(cmdManualPath(p)))
}
return nil
}
type commandGroup struct {
Name string
Commands []*cobra.Command
}
// subcommandGroups lists child commands of a Cobra command split into groups.
// TODO: have rootHelpFunc use this instead of repeating the same logic.
func subcommandGroups(c *cobra.Command) []commandGroup {
var rest []*cobra.Command
var core []*cobra.Command
var actions []*cobra.Command
for _, subcmd := range c.Commands() {
if !subcmd.IsAvailableCommand() {
continue
}
if _, ok := subcmd.Annotations["IsCore"]; ok {
core = append(core, subcmd)
} else if _, ok := subcmd.Annotations["IsActions"]; ok {
actions = append(actions, subcmd)
} else {
rest = append(rest, subcmd)
}
}
if len(core) > 0 {
return []commandGroup{
{
Name: "Core commands",
Commands: core,
},
{
Name: "Actions commands",
Commands: actions,
},
{
Name: "Additional commands",
Commands: rest,
},
}
}
return []commandGroup{
{
Name: "Commands",
Commands: rest,
},
}
_, err := buf.WriteTo(w)
return err
}
// GenMarkdownTree will generate a markdown page for this command and all
@ -92,12 +204,7 @@ func GenMarkdownTreeCustom(cmd *cobra.Command, dir string, filePrepender, linkHa
}
}
basename := strings.Replace(cmd.CommandPath(), " ", "_", -1) + ".md"
if basenameOverride, found := cmd.Annotations["markdown:basename"]; found {
basename = basenameOverride + ".md"
}
filename := filepath.Join(dir, basename)
filename := filepath.Join(dir, cmdManualPath(cmd))
f, err := os.Create(filename)
if err != nil {
return err
@ -112,3 +219,10 @@ func GenMarkdownTreeCustom(cmd *cobra.Command, dir string, filePrepender, linkHa
}
return nil
}
func cmdManualPath(c *cobra.Command) string {
if basenameOverride, found := c.Annotations["markdown:basename"]; found {
return basenameOverride + ".md"
}
return strings.ReplaceAll(c.CommandPath(), " ", "_") + ".md"
}

View file

@ -8,6 +8,9 @@ import (
const defaultHostname = "github.com"
// localhost is the domain name of a local GitHub instance
const localhost = "github.localhost"
// Default returns the host name of the default GitHub instance
func Default() string {
return defaultHostname
@ -15,7 +18,8 @@ func Default() string {
// IsEnterprise reports whether a non-normalized host name looks like a GHE instance
func IsEnterprise(h string) bool {
return NormalizeHostname(h) != defaultHostname
normalizedHostName := NormalizeHostname(h)
return normalizedHostName != defaultHostname && normalizedHostName != localhost
}
// NormalizeHostname returns the canonical host name of a GitHub instance
@ -24,6 +28,11 @@ func NormalizeHostname(h string) string {
if strings.HasSuffix(hostname, "."+defaultHostname) {
return defaultHostname
}
if strings.HasSuffix(hostname, "."+localhost) {
return localhost
}
return hostname
}
@ -46,19 +55,45 @@ func GraphQLEndpoint(hostname string) string {
if IsEnterprise(hostname) {
return fmt.Sprintf("https://%s/api/graphql", hostname)
}
return "https://api.github.com/graphql"
if strings.EqualFold(hostname, localhost) {
return fmt.Sprintf("http://api.%s/graphql", hostname)
}
return fmt.Sprintf("https://api.%s/graphql", hostname)
}
func RESTPrefix(hostname string) string {
if IsEnterprise(hostname) {
return fmt.Sprintf("https://%s/api/v3/", hostname)
}
return "https://api.github.com/"
if strings.EqualFold(hostname, localhost) {
return fmt.Sprintf("http://api.%s/", hostname)
}
return fmt.Sprintf("https://api.%s/", hostname)
}
func GistPrefix(hostname string) string {
if IsEnterprise(hostname) {
return fmt.Sprintf("https://%s/gist/", hostname)
prefix := "https://"
if strings.EqualFold(hostname, localhost) {
prefix = "http://"
}
return fmt.Sprintf("https://gist.%s/", hostname)
return prefix + GistHost(hostname)
}
func GistHost(hostname string) string {
if IsEnterprise(hostname) {
return fmt.Sprintf("%s/gist/", hostname)
}
if strings.EqualFold(hostname, localhost) {
return fmt.Sprintf("%s/gist/", hostname)
}
return fmt.Sprintf("gist.%s/", hostname)
}
func HostPrefix(hostname string) string {
if strings.EqualFold(hostname, localhost) {
return fmt.Sprintf("http://%s/", hostname)
}
return fmt.Sprintf("https://%s/", hostname)
}

View file

@ -19,6 +19,14 @@ func TestIsEnterprise(t *testing.T) {
host: "api.github.com",
want: false,
},
{
host: "github.localhost",
want: false,
},
{
host: "api.github.localhost",
want: false,
},
{
host: "ghe.io",
want: true,
@ -58,6 +66,14 @@ func TestNormalizeHostname(t *testing.T) {
host: "upload.github.com",
want: "github.com",
},
{
host: "GitHub.localhost",
want: "github.localhost",
},
{
host: "api.github.localhost",
want: "github.localhost",
},
{
host: "GHE.IO",
want: "ghe.io",
@ -129,6 +145,10 @@ func TestGraphQLEndpoint(t *testing.T) {
host: "github.com",
want: "https://api.github.com/graphql",
},
{
host: "github.localhost",
want: "http://api.github.localhost/graphql",
},
{
host: "ghe.io",
want: "https://ghe.io/api/graphql",
@ -152,6 +172,10 @@ func TestRESTPrefix(t *testing.T) {
host: "github.com",
want: "https://api.github.com/",
},
{
host: "github.localhost",
want: "http://api.github.localhost/",
},
{
host: "ghe.io",
want: "https://ghe.io/api/v3/",

View file

@ -53,6 +53,12 @@ func SetDefaultHost(host string) {
// FromFullName extracts the GitHub repository information from the following
// formats: "OWNER/REPO", "HOST/OWNER/REPO", and a full URL.
func FromFullName(nwo string) (Interface, error) {
return FromFullNameWithHost(nwo, defaultHost())
}
// FromFullNameWithHost is like FromFullName that defaults to a specific host for values that don't
// explicitly include a hostname.
func FromFullNameWithHost(nwo, fallbackHost string) (Interface, error) {
if git.IsURL(nwo) {
u, err := git.ParseURL(nwo)
if err != nil {
@ -71,7 +77,7 @@ func FromFullName(nwo string) (Interface, error) {
case 3:
return NewWithHost(parts[1], parts[2], parts[0]), nil
case 2:
return NewWithHost(parts[0], parts[1], defaultHost()), nil
return NewWithHost(parts[0], parts[1], fallbackHost), nil
default:
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo)
}
@ -103,9 +109,11 @@ func IsSame(a, b Interface) bool {
}
func GenerateRepoURL(repo Interface, p string, args ...interface{}) string {
baseURL := fmt.Sprintf("https://%s/%s/%s", repo.RepoHost(), repo.RepoOwner(), repo.RepoName())
baseURL := fmt.Sprintf("%s%s/%s", ghinstance.HostPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName())
if p != "" {
return baseURL + "/" + fmt.Sprintf(p, args...)
if path := fmt.Sprintf(p, args...); path != "" {
return baseURL + "/" + path
}
}
return baseURL
}
@ -116,7 +124,7 @@ func FormatRemoteURL(repo Interface, protocol string) string {
return fmt.Sprintf("git@%s:%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName())
}
return fmt.Sprintf("https://%s/%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName())
return fmt.Sprintf("%s%s/%s.git", ghinstance.HostPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName())
}
type ghRepo struct {

View file

@ -1,8 +1,6 @@
package actions
import (
"fmt"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
@ -14,11 +12,8 @@ func NewCmdActions(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "actions",
Short: "Learn about working with GitHub actions",
Short: "Learn about working with GitHub Actions",
Long: actionsExplainer(cs),
Run: func(cmd *cobra.Command, args []string) {
fmt.Fprintln(f.IOStreams.Out, actionsExplainer(cs))
},
Annotations: map[string]string{
"IsActions": "true",
},

View file

@ -21,6 +21,7 @@ import (
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/factory"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/export"
"github.com/cli/cli/v2/pkg/iostreams"
@ -71,21 +72,23 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
The endpoint argument should either be a path of a GitHub API v3 endpoint, or
"graphql" to access the GitHub API v4.
Placeholder values "{owner}", "{repo}", and "{branch}" in the endpoint argument will
get replaced with values from the repository of the current directory. Note that in
some shells, for example PowerShell, you may need to enclose any value that contains
"{...}" in quotes to prevent the shell from applying special meaning to curly braces.
Placeholder values "{owner}", "{repo}", and "{branch}" in the endpoint
argument will get replaced with values from the repository of the current
directory or the repository specified in the GH_REPO environment variable.
Note that in some shells, for example PowerShell, you may need to enclose
any value that contains "{...}" in quotes to prevent the shell from
applying special meaning to curly braces.
The default HTTP request method is "GET" normally and "POST" if any parameters
were added. Override the method with %[1]s--method%[1]s.
Pass one or more %[1]s--raw-field%[1]s values in "key=value" format to add string
parameters to the request payload. To add non-string parameters, see %[1]s--field%[1]s below.
Note that adding request parameters will automatically switch the request method to POST.
To send the parameters as a GET query string instead, use %[1]s--method%[1]s GET.
Pass one or more %[1]s-f/--raw-field%[1]s values in "key=value" format to add static string
parameters to the request payload. To add non-string or otherwise dynamic values, see
%[1]s--field%[1]s below. Note that adding request parameters will automatically switch the
request method to POST. To send the parameters as a GET query string instead, use
%[1]s--method GET%[1]s.
The %[1]s--field%[1]s flag behaves like %[1]s--raw-field%[1]s with magic type conversion based
on the format of the value:
The %[1]s-F/--field%[1]s flag has magic type conversion based on the format of the value:
- literal values "true", "false", "null", and integer numbers get converted to
appropriate JSON types;
@ -167,18 +170,21 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
`),
},
Args: cobra.ExactArgs(1),
PreRun: func(c *cobra.Command, args []string) {
opts.BaseRepo = cmdutil.OverrideBaseRepoFunc(f, "")
},
RunE: func(c *cobra.Command, args []string) error {
opts.RequestPath = args[0]
opts.RequestMethodPassed = c.Flags().Changed("method")
if c.Flags().Changed("hostname") {
if err := ghinstance.HostnameValidator(opts.Hostname); err != nil {
return &cmdutil.FlagError{Err: fmt.Errorf("error parsing `--hostname`: %w", err)}
return cmdutil.FlagErrorf("error parsing `--hostname`: %w", err)
}
}
if opts.Paginate && !strings.EqualFold(opts.RequestMethod, "GET") && opts.RequestPath != "graphql" {
return &cmdutil.FlagError{Err: errors.New("the `--paginate` option is not supported for non-GET requests")}
return cmdutil.FlagErrorf("the `--paginate` option is not supported for non-GET requests")
}
if err := cmdutil.MutuallyExclusive(
@ -213,7 +219,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
cmd.Flags().StringSliceVarP(&opts.Previews, "preview", "p", nil, "Opt into GitHub API previews")
cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output")
cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results")
cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request")
cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request (use \"-\" to read from standard input)")
cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Do not print the response body")
cmd.Flags().StringVarP(&opts.Template, "template", "t", "", "Format the response using a Go template")
cmd.Flags().StringVarP(&opts.FilterOutput, "jq", "q", "", "Query to select values from the response using jq syntax")
@ -319,6 +325,7 @@ func apiRun(opts *ApiOptions) error {
}
} else {
requestPath, hasNextPage = findNextPage(resp)
requestBody = nil // prevent repeating GET parameters
}
if hasNextPage && opts.ShowResponseHeaders {
@ -384,12 +391,17 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
}
}
if serverError == "" && resp.StatusCode > 299 {
serverError = fmt.Sprintf("HTTP %d", resp.StatusCode)
}
if serverError != "" {
fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", serverError)
err = cmdutil.SilentError
return
} else if resp.StatusCode > 299 {
fmt.Fprintf(opts.IO.ErrOut, "gh: HTTP %d\n", resp.StatusCode)
if msg := api.ScopesSuggestion(resp); msg != "" {
fmt.Fprintf(opts.IO.ErrOut, "gh: %s\n", msg)
}
if u := factory.SSOURL(); u != "" {
fmt.Fprintf(opts.IO.ErrOut, "Authorize in your web browser: %s\n", u)
}
err = cmdutil.SilentError
return
}
@ -537,36 +549,58 @@ func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error)
var parsedBody struct {
Message string
Errors []json.RawMessage
Errors json.RawMessage
}
err = json.Unmarshal(b, &parsedBody)
if err != nil {
return r, "", err
return bodyCopy, "", err
}
if len(parsedBody.Errors) > 0 && parsedBody.Errors[0] == '"' {
var stringError string
if err := json.Unmarshal(parsedBody.Errors, &stringError); err != nil {
return bodyCopy, "", err
}
if stringError != "" {
if parsedBody.Message != "" {
return bodyCopy, fmt.Sprintf("%s (%s)", stringError, parsedBody.Message), nil
}
return bodyCopy, stringError, nil
}
}
if parsedBody.Message != "" {
return bodyCopy, fmt.Sprintf("%s (HTTP %d)", parsedBody.Message, statusCode), nil
}
type errorMessage struct {
if len(parsedBody.Errors) == 0 || parsedBody.Errors[0] != '[' {
return bodyCopy, "", nil
}
var errorObjects []json.RawMessage
if err := json.Unmarshal(parsedBody.Errors, &errorObjects); err != nil {
return bodyCopy, "", err
}
var objectError struct {
Message string
}
var errors []string
for _, rawErr := range parsedBody.Errors {
for _, rawErr := range errorObjects {
if len(rawErr) == 0 {
continue
}
if rawErr[0] == '{' {
var objectError errorMessage
err := json.Unmarshal(rawErr, &objectError)
if err != nil {
return r, "", err
return bodyCopy, "", err
}
errors = append(errors, objectError.Message)
} else if rawErr[0] == '"' {
var stringError string
err := json.Unmarshal(rawErr, &stringError)
if err != nil {
return r, "", err
return bodyCopy, "", err
}
errors = append(errors, stringError)
}

View file

@ -577,8 +577,11 @@ func Test_apiRun_paginationREST(t *testing.T) {
return config.NewBlankConfig(), nil
},
RequestPath: "issues",
Paginate: true,
RequestMethod: "GET",
RequestMethodPassed: true,
RequestPath: "issues",
Paginate: true,
RawFields: []string{"per_page=50", "page=1"},
}
err := apiRun(&options)
@ -587,7 +590,7 @@ func Test_apiRun_paginationREST(t *testing.T) {
assert.Equal(t, `{"page":1}{"page":2}{"page":3}`, stdout.String(), "stdout")
assert.Equal(t, "", stderr.String(), "stderr")
assert.Equal(t, "https://api.github.com/issues?per_page=100", responses[0].Request.URL.String())
assert.Equal(t, "https://api.github.com/issues?page=1&per_page=50", responses[0].Request.URL.String())
assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=2", responses[1].Request.URL.String())
assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=3", responses[2].Request.URL.String())
}
@ -1281,3 +1284,85 @@ func Test_processResponse_template(t *testing.T) {
`), stdout.String())
assert.Equal(t, "", stderr.String())
}
func Test_parseErrorResponse(t *testing.T) {
type args struct {
input string
statusCode int
}
tests := []struct {
name string
args args
wantErrMsg string
wantErr bool
}{
{
name: "no error",
args: args{
input: `{}`,
statusCode: 500,
},
wantErrMsg: "",
wantErr: false,
},
{
name: "nil errors",
args: args{
input: `{"errors":null}`,
statusCode: 500,
},
wantErrMsg: "",
wantErr: false,
},
{
name: "simple error",
args: args{
input: `{"message": "OH NOES"}`,
statusCode: 500,
},
wantErrMsg: "OH NOES (HTTP 500)",
wantErr: false,
},
{
name: "errors string",
args: args{
input: `{"message": "Conflict", "errors": "Some description"}`,
statusCode: 409,
},
wantErrMsg: "Some description (Conflict)",
wantErr: false,
},
{
name: "errors array of strings",
args: args{
input: `{"errors": ["fail1", "asplode2"]}`,
statusCode: 500,
},
wantErrMsg: "fail1\nasplode2",
wantErr: false,
},
{
name: "errors array of objects",
args: args{
input: `{"errors": [{"message":"fail1"}, {"message":"asplode2"}]}`,
statusCode: 500,
},
wantErrMsg: "fail1\nasplode2",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, got1, err := parseErrorResponse(strings.NewReader(tt.args.input), tt.args.statusCode)
if (err != nil) != tt.wantErr {
t.Errorf("parseErrorResponse() error = %v, wantErr %v", err, tt.wantErr)
}
if gotString, _ := ioutil.ReadAll(got); tt.args.input != string(gotString) {
t.Errorf("parseErrorResponse() got = %q, want %q", string(gotString), tt.args.input)
}
if got1 != tt.wantErrMsg {
t.Errorf("parseErrorResponse() got1 = %q, want %q", got1, tt.wantErrMsg)
}
})
}
}

View file

@ -5,6 +5,7 @@ import (
authLoginCmd "github.com/cli/cli/v2/pkg/cmd/auth/login"
authLogoutCmd "github.com/cli/cli/v2/pkg/cmd/auth/logout"
authRefreshCmd "github.com/cli/cli/v2/pkg/cmd/auth/refresh"
authSetupGitCmd "github.com/cli/cli/v2/pkg/cmd/auth/setupgit"
authStatusCmd "github.com/cli/cli/v2/pkg/cmd/auth/status"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
@ -24,6 +25,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(authStatusCmd.NewCmdStatus(f, nil))
cmd.AddCommand(authRefreshCmd.NewCmdRefresh(f, nil))
cmd.AddCommand(gitCredentialCmd.NewCmdCredential(f, nil))
cmd.AddCommand(authSetupGitCmd.NewCmdSetupGit(f, nil))
return cmd
}

View file

@ -100,12 +100,18 @@ func helperRun(opts *CredentialOptions) error {
return err
}
lookupHost := wants["host"]
var gotUser string
gotToken, source, _ := cfg.GetWithSource(wants["host"], "oauth_token")
gotToken, source, _ := cfg.GetWithSource(lookupHost, "oauth_token")
if gotToken == "" && strings.HasPrefix(lookupHost, "gist.") {
lookupHost = strings.TrimPrefix(lookupHost, "gist.")
gotToken, source, _ = cfg.GetWithSource(lookupHost, "oauth_token")
}
if strings.HasSuffix(source, "_TOKEN") {
gotUser = tokenUser
} else {
gotUser, _, _ = cfg.GetWithSource(wants["host"], "user")
gotUser, _, _ = cfg.GetWithSource(lookupHost, "user")
}
if gotUser == "" || gotToken == "" {

View file

@ -8,12 +8,18 @@ import (
"github.com/cli/cli/v2/pkg/iostreams"
)
// why not just use the config stub argh
type tinyConfig map[string]string
func (c tinyConfig) GetWithSource(host, key string) (string, string, error) {
return c[fmt.Sprintf("%s:%s", host, key)], c["_source"], nil
}
func (c tinyConfig) Get(host, key string) (val string, err error) {
val, _, err = c.GetWithSource(host, key)
return
}
func Test_helperRun(t *testing.T) {
tests := []struct {
name string
@ -74,6 +80,32 @@ func Test_helperRun(t *testing.T) {
`),
wantStderr: "",
},
{
name: "gist host",
opts: CredentialOptions{
Operation: "get",
Config: func() (config, error) {
return tinyConfig{
"_source": "/Users/monalisa/.config/gh/hosts.yml",
"github.com:user": "monalisa",
"github.com:oauth_token": "OTOKEN",
}, nil
},
},
input: heredoc.Doc(`
protocol=https
host=gist.github.com
username=monalisa
`),
wantErr: false,
wantStdout: heredoc.Doc(`
protocol=https
host=gist.github.com
username=monalisa
password=OTOKEN
`),
wantStderr: "",
},
{
name: "url input",
opts: CredentialOptions{

View file

@ -38,8 +38,6 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
IO: f.IOStreams,
Config: f.Config,
HttpClient: f.HttpClient,
MainExecutable: f.Executable,
}
var tokenStdin bool
@ -70,39 +68,37 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
$ gh auth login --hostname enterprise.internal
`),
RunE: func(cmd *cobra.Command, args []string) error {
if !opts.IO.CanPrompt() && !(tokenStdin || opts.Web) {
return &cmdutil.FlagError{Err: errors.New("--web or --with-token required when not running interactively")}
}
if tokenStdin && opts.Web {
return &cmdutil.FlagError{Err: errors.New("specify only one of --web or --with-token")}
return cmdutil.FlagErrorf("specify only one of `--web` or `--with-token`")
}
if tokenStdin && len(opts.Scopes) > 0 {
return cmdutil.FlagErrorf("specify only one of `--scopes` or `--with-token`")
}
if tokenStdin {
defer opts.IO.In.Close()
token, err := ioutil.ReadAll(opts.IO.In)
if err != nil {
return fmt.Errorf("failed to read token from STDIN: %w", err)
return fmt.Errorf("failed to read token from standard input: %w", err)
}
opts.Token = strings.TrimSpace(string(token))
}
if opts.IO.CanPrompt() && opts.Token == "" && !opts.Web {
if opts.IO.CanPrompt() && opts.Token == "" {
opts.Interactive = true
}
if cmd.Flags().Changed("hostname") {
if err := ghinstance.HostnameValidator(opts.Hostname); err != nil {
return &cmdutil.FlagError{Err: fmt.Errorf("error parsing --hostname: %w", err)}
return cmdutil.FlagErrorf("error parsing hostname: %w", err)
}
}
if !opts.Interactive {
if opts.Hostname == "" {
opts.Hostname = ghinstance.Default()
}
if opts.Hostname == "" && (!opts.Interactive || opts.Web) {
opts.Hostname = ghinstance.Default()
}
opts.MainExecutable = f.Executable()
if runF != nil {
return runF(opts)
}
@ -126,15 +122,11 @@ func loginRun(opts *LoginOptions) error {
}
hostname := opts.Hostname
if hostname == "" {
if opts.Interactive {
var err error
hostname, err = promptForHostname()
if err != nil {
return err
}
} else {
return errors.New("must specify --hostname")
if opts.Interactive && hostname == "" {
var err error
hostname, err = promptForHostname()
if err != nil {
return err
}
}

View file

@ -5,6 +5,7 @@ import (
"net/http"
"os"
"regexp"
"runtime"
"testing"
"github.com/MakeNowJust/heredoc"
@ -18,6 +19,21 @@ import (
"github.com/stretchr/testify/assert"
)
func stubHomeDir(t *testing.T, dir string) {
homeEnv := "HOME"
switch runtime.GOOS {
case "windows":
homeEnv = "USERPROFILE"
case "plan9":
homeEnv = "home"
}
oldHomeDir := os.Getenv(homeEnv)
os.Setenv(homeEnv, dir)
t.Cleanup(func() {
os.Setenv(homeEnv, oldHomeDir)
})
}
func Test_NewCmdLogin(t *testing.T) {
tests := []struct {
name string
@ -50,13 +66,19 @@ func Test_NewCmdLogin(t *testing.T) {
name: "nontty, hostname",
stdinTTY: false,
cli: "--hostname claire.redfield",
wantsErr: true,
wants: LoginOptions{
Hostname: "claire.redfield",
Token: "",
},
},
{
name: "nontty",
stdinTTY: false,
cli: "",
wantsErr: true,
wants: LoginOptions{
Hostname: "github.com",
Token: "",
},
},
{
name: "nontty, with-token, hostname",
@ -102,8 +124,9 @@ func Test_NewCmdLogin(t *testing.T) {
stdinTTY: true,
cli: "--web",
wants: LoginOptions{
Hostname: "github.com",
Web: true,
Hostname: "github.com",
Web: true,
Interactive: true,
},
},
{
@ -345,6 +368,8 @@ func Test_loginRun_nontty(t *testing.T) {
}
func Test_loginRun_Survey(t *testing.T) {
stubHomeDir(t, t.TempDir())
tests := []struct {
name string
opts *LoginOptions
@ -370,8 +395,8 @@ func Test_loginRun_Survey(t *testing.T) {
// httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
},
askStubs: func(as *prompt.AskStubber) {
as.StubOne(0) // host type github.com
as.StubOne(false) // do not continue
as.StubPrompt("What account do you want to log into?").AnswerWith("GitHub.com")
as.StubPrompt("You're already logged into github.com. Do you want to re-authenticate?").AnswerWith(false)
},
wantHosts: "", // nothing should have been written to hosts
wantErrOut: nil,
@ -389,10 +414,10 @@ func Test_loginRun_Survey(t *testing.T) {
git_protocol: https
`),
askStubs: func(as *prompt.AskStubber) {
as.StubOne("HTTPS") // git_protocol
as.StubOne(false) // cache credentials
as.StubOne(1) // auth mode: token
as.StubOne("def456") // auth token
as.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("HTTPS")
as.StubPrompt("Authenticate Git with your GitHub credentials?").AnswerWith(false)
as.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token")
as.StubPrompt("Paste your authentication token:").AnswerWith("def456")
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git config credential\.https:/`, 1, "")
@ -418,12 +443,12 @@ func Test_loginRun_Survey(t *testing.T) {
Interactive: true,
},
askStubs: func(as *prompt.AskStubber) {
as.StubOne(1) // host type enterprise
as.StubOne("brad.vickers") // hostname
as.StubOne("HTTPS") // git_protocol
as.StubOne(false) // cache credentials
as.StubOne(1) // auth mode: token
as.StubOne("def456") // auth token
as.StubPrompt("What account do you want to log into?").AnswerWith("GitHub Enterprise Server")
as.StubPrompt("GHE hostname:").AnswerWith("brad.vickers")
as.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("HTTPS")
as.StubPrompt("Authenticate Git with your GitHub credentials?").AnswerWith(false)
as.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token")
as.StubPrompt("Paste your authentication token:").AnswerWith("def456")
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git config credential\.https:/`, 1, "")
@ -449,11 +474,11 @@ func Test_loginRun_Survey(t *testing.T) {
Interactive: true,
},
askStubs: func(as *prompt.AskStubber) {
as.StubOne(0) // host type github.com
as.StubOne("HTTPS") // git_protocol
as.StubOne(false) // cache credentials
as.StubOne(1) // auth mode: token
as.StubOne("def456") // auth token
as.StubPrompt("What account do you want to log into?").AnswerWith("GitHub.com")
as.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("HTTPS")
as.StubPrompt("Authenticate Git with your GitHub credentials?").AnswerWith(false)
as.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token")
as.StubPrompt("Paste your authentication token:").AnswerWith("def456")
},
runStubs: func(rs *run.CommandStubber) {
rs.Register(`git config credential\.https:/`, 1, "")
@ -473,11 +498,11 @@ func Test_loginRun_Survey(t *testing.T) {
Interactive: true,
},
askStubs: func(as *prompt.AskStubber) {
as.StubOne(0) // host type github.com
as.StubOne("SSH") // git_protocol
as.StubOne(10) // TODO: SSH key selection
as.StubOne(1) // auth mode: token
as.StubOne("def456") // auth token
as.StubPrompt("What account do you want to log into?").AnswerWith("GitHub.com")
as.StubPrompt("What is your preferred protocol for Git operations?").AnswerWith("SSH")
as.StubPrompt("Generate a new SSH key to add to your GitHub account?").AnswerWith(false)
as.StubPrompt("How would you like to authenticate GitHub CLI?").AnswerWith("Paste an authentication token")
as.StubPrompt("Paste your authentication token:").AnswerWith("def456")
},
wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://github.com/settings/tokens"),
},
@ -523,8 +548,7 @@ func Test_loginRun_Survey(t *testing.T) {
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
as, teardown := prompt.InitAskStubber()
defer teardown()
as := prompt.NewAskStubber(t)
if tt.askStubs != nil {
tt.askStubs(as)
}

View file

@ -48,7 +48,7 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co
`),
RunE: func(cmd *cobra.Command, args []string) error {
if opts.Hostname == "" && !opts.IO.CanPrompt() {
return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")}
return cmdutil.FlagErrorf("--hostname required when not running interactively")
}
if runF != nil {

View file

@ -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)
}

View file

@ -3,6 +3,8 @@ package refresh
import (
"errors"
"fmt"
"net/http"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
@ -16,14 +18,15 @@ import (
)
type RefreshOptions struct {
IO *iostreams.IOStreams
Config func() (config.Config, error)
IO *iostreams.IOStreams
Config func() (config.Config, error)
httpClient *http.Client
MainExecutable string
Hostname string
Scopes []string
AuthFlow func(config.Config, *iostreams.IOStreams, string, []string) error
AuthFlow func(config.Config, *iostreams.IOStreams, string, []string, bool) error
Interactive bool
}
@ -32,11 +35,11 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
opts := &RefreshOptions{
IO: f.IOStreams,
Config: f.Config,
AuthFlow: func(cfg config.Config, io *iostreams.IOStreams, hostname string, scopes []string) error {
_, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes)
AuthFlow: func(cfg config.Config, io *iostreams.IOStreams, hostname string, scopes []string, interactive bool) error {
_, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes, interactive)
return err
},
MainExecutable: f.Executable,
httpClient: http.DefaultClient,
}
cmd := &cobra.Command{
@ -59,9 +62,10 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
opts.Interactive = opts.IO.CanPrompt()
if !opts.Interactive && opts.Hostname == "" {
return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")}
return cmdutil.FlagErrorf("--hostname required when not running interactively")
}
opts.MainExecutable = f.Executable()
if runF != nil {
return runF(opts)
}
@ -128,9 +132,21 @@ func refreshRun(opts *RefreshOptions) error {
}
var additionalScopes []string
if oldToken, _ := cfg.Get(hostname, "oauth_token"); oldToken != "" {
if oldScopes, err := shared.GetScopes(opts.httpClient, hostname, oldToken); err == nil {
for _, s := range strings.Split(oldScopes, ",") {
s = strings.TrimSpace(s)
if s != "" {
additionalScopes = append(additionalScopes, s)
}
}
}
}
credentialFlow := &shared.GitCredentialFlow{}
gitProtocol, _ := cfg.Get(hostname, "git_protocol")
credentialFlow := &shared.GitCredentialFlow{
Executable: opts.MainExecutable,
}
gitProtocol, _ := cfg.GetOrDefault(hostname, "git_protocol")
if opts.Interactive && gitProtocol == "https" {
if err := credentialFlow.Prompt(hostname); err != nil {
return err
@ -138,10 +154,13 @@ func refreshRun(opts *RefreshOptions) error {
additionalScopes = append(additionalScopes, credentialFlow.Scopes()...)
}
if err := opts.AuthFlow(cfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...)); err != nil {
if err := opts.AuthFlow(cfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...), opts.Interactive); err != nil {
return err
}
cs := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.ErrOut, "%s Authentication complete.\n", cs.SuccessIcon())
if credentialFlow.ShouldSetup() {
username, _ := cfg.Get(hostname, "user")
password, _ := cfg.Get(hostname, "oauth_token")

View file

@ -2,6 +2,9 @@ package refresh
import (
"bytes"
"io/ioutil"
"net/http"
"strings"
"testing"
"github.com/cli/cli/v2/internal/config"
@ -133,6 +136,7 @@ func Test_refreshRun(t *testing.T) {
opts *RefreshOptions
askStubs func(*prompt.AskStubber)
cfgHosts []string
oldScopes string
wantErr string
nontty bool
wantAuthArgs authArgs
@ -190,7 +194,7 @@ func Test_refreshRun(t *testing.T) {
Hostname: "",
},
askStubs: func(as *prompt.AskStubber) {
as.StubOne("github.com")
as.StubPrompt("What account do you want to refresh auth for?").AnswerWith("github.com")
},
wantAuthArgs: authArgs{
hostname: "github.com",
@ -210,11 +214,25 @@ func Test_refreshRun(t *testing.T) {
scopes: []string{"repo:invite", "public_key:read"},
},
},
{
name: "scopes provided",
cfgHosts: []string{
"github.com",
},
oldScopes: "delete_repo, codespace",
opts: &RefreshOptions{
Scopes: []string{"repo:invite", "public_key:read"},
},
wantAuthArgs: authArgs{
hostname: "github.com",
scopes: []string{"repo:invite", "public_key:read", "delete_repo", "codespace"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
aa := authArgs{}
tt.opts.AuthFlow = func(_ config.Config, _ *iostreams.IOStreams, hostname string, scopes []string) error {
tt.opts.AuthFlow = func(_ config.Config, _ *iostreams.IOStreams, hostname string, scopes []string, interactive bool) error {
aa.hostname = hostname
aa.scopes = scopes
return nil
@ -233,17 +251,32 @@ func Test_refreshRun(t *testing.T) {
for _, hostname := range tt.cfgHosts {
_ = cfg.Set(hostname, "oauth_token", "abc123")
}
reg := &httpmock.Registry{}
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"cybilb"}}}`))
httpReg := &httpmock.Registry{}
httpReg.Register(
httpmock.REST("GET", ""),
func(req *http.Request) (*http.Response, error) {
statusCode := 200
if req.Header.Get("Authorization") != "token abc123" {
statusCode = 400
}
return &http.Response{
Request: req,
StatusCode: statusCode,
Body: ioutil.NopCloser(strings.NewReader(``)),
Header: http.Header{
"X-Oauth-Scopes": {tt.oldScopes},
},
}, nil
},
)
tt.opts.httpClient = &http.Client{Transport: httpReg}
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
as, teardown := prompt.InitAskStubber()
defer teardown()
as := prompt.NewAskStubber(t)
if tt.askStubs != nil {
tt.askStubs(as)
}
@ -257,8 +290,8 @@ func Test_refreshRun(t *testing.T) {
assert.NoError(t, err)
}
assert.Equal(t, aa.hostname, tt.wantAuthArgs.hostname)
assert.Equal(t, aa.scopes, tt.wantAuthArgs.scopes)
assert.Equal(t, tt.wantAuthArgs.hostname, aa.hostname)
assert.Equal(t, tt.wantAuthArgs.scopes, aa.scopes)
})
}
}

View file

@ -0,0 +1,100 @@
package setupgit
import (
"fmt"
"strings"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmd/auth/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
type gitConfigurator interface {
Setup(hostname, username, authToken string) error
}
type SetupGitOptions struct {
IO *iostreams.IOStreams
Config func() (config.Config, error)
Hostname string
gitConfigure gitConfigurator
}
func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobra.Command {
opts := &SetupGitOptions{
IO: f.IOStreams,
Config: f.Config,
}
cmd := &cobra.Command{
Short: "Configure git to use GitHub CLI as a credential helper",
Use: "setup-git",
RunE: func(cmd *cobra.Command, args []string) error {
opts.gitConfigure = &shared.GitCredentialFlow{
Executable: f.Executable(),
}
if runF != nil {
return runF(opts)
}
return setupGitRun(opts)
},
}
cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname to configure git for")
return cmd
}
func setupGitRun(opts *SetupGitOptions) error {
cfg, err := opts.Config()
if err != nil {
return err
}
hostnames, err := cfg.Hosts()
if err != nil {
return err
}
stderr := opts.IO.ErrOut
cs := opts.IO.ColorScheme()
if len(hostnames) == 0 {
fmt.Fprintf(
stderr,
"You are not logged into any GitHub hosts. Run %s to authenticate.\n",
cs.Bold("gh auth login"),
)
return cmdutil.SilentError
}
hostnamesToSetup := hostnames
if opts.Hostname != "" {
if !has(opts.Hostname, hostnames) {
return fmt.Errorf("You are not logged into the GitHub host %q\n", opts.Hostname)
}
hostnamesToSetup = []string{opts.Hostname}
}
for _, hostname := range hostnamesToSetup {
if err := opts.gitConfigure.Setup(hostname, "", ""); err != nil {
return fmt.Errorf("failed to set up git credential helper: %w", err)
}
}
return nil
}
func has(needle string, haystack []string) bool {
for _, s := range haystack {
if strings.EqualFold(s, needle) {
return true
}
}
return false
}

View file

@ -0,0 +1,122 @@
package setupgit
import (
"fmt"
"testing"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockGitConfigurer struct {
setupErr error
}
func (gf *mockGitConfigurer) Setup(hostname, username, authToken string) error {
return gf.setupErr
}
func Test_setupGitRun(t *testing.T) {
tests := []struct {
name string
opts *SetupGitOptions
expectedErr string
expectedErrOut string
}{
{
name: "opts.Config returns an error",
opts: &SetupGitOptions{
Config: func() (config.Config, error) {
return nil, fmt.Errorf("oops")
},
},
expectedErr: "oops",
},
{
name: "no authenticated hostnames",
opts: &SetupGitOptions{},
expectedErr: "SilentError",
expectedErrOut: "You are not logged into any GitHub hosts. Run gh auth login to authenticate.\n",
},
{
name: "not authenticated with the hostname given as flag",
opts: &SetupGitOptions{
Hostname: "foo",
Config: func() (config.Config, error) {
cfg := config.NewBlankConfig()
require.NoError(t, cfg.Set("bar", "", ""))
return cfg, nil
},
},
expectedErr: "You are not logged into the GitHub host \"foo\"\n",
expectedErrOut: "",
},
{
name: "error setting up git for hostname",
opts: &SetupGitOptions{
gitConfigure: &mockGitConfigurer{
setupErr: fmt.Errorf("broken"),
},
Config: func() (config.Config, error) {
cfg := config.NewBlankConfig()
require.NoError(t, cfg.Set("bar", "", ""))
return cfg, nil
},
},
expectedErr: "failed to set up git credential helper: broken",
expectedErrOut: "",
},
{
name: "no hostname option given. Setup git for each hostname in config",
opts: &SetupGitOptions{
gitConfigure: &mockGitConfigurer{},
Config: func() (config.Config, error) {
cfg := config.NewBlankConfig()
require.NoError(t, cfg.Set("bar", "", ""))
return cfg, nil
},
},
},
{
name: "setup git for the hostname given via options",
opts: &SetupGitOptions{
Hostname: "yes",
gitConfigure: &mockGitConfigurer{},
Config: func() (config.Config, error) {
cfg := config.NewBlankConfig()
require.NoError(t, cfg.Set("bar", "", ""))
require.NoError(t, cfg.Set("yes", "", ""))
return cfg, nil
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.opts.Config == nil {
tt.opts.Config = func() (config.Config, error) {
return config.NewBlankConfig(), nil
}
}
io, _, _, stderr := iostreams.Test()
io.SetStdinTTY(true)
io.SetStderrTTY(true)
io.SetStdoutTTY(true)
tt.opts.IO = io
err := setupGitRun(tt.opts)
if tt.expectedErr != "" {
assert.EqualError(t, err, tt.expectedErr)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.expectedErrOut, stderr.String())
})
}
}

View file

@ -10,6 +10,7 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/cli/v2/pkg/prompt"
"github.com/google/shlex"
@ -62,25 +63,46 @@ func (flow *GitCredentialFlow) Setup(hostname, username, authToken string) error
func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password string) error {
if flow.helper == "" {
// first use a blank value to indicate to git we want to sever the chain of credential helpers
preConfigureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "")
if err != nil {
return err
}
if err = run.PrepareCmd(preConfigureCmd).Run(); err != nil {
return err
credHelperKeys := []string{
gitCredentialHelperKey(hostname),
}
// use GitHub CLI as a credential helper (for this host only)
configureCmd, err := git.GitCommand(
"config", "--global", "--add",
gitCredentialHelperKey(hostname),
fmt.Sprintf("!%s auth git-credential", shellQuote(flow.Executable)),
)
if err != nil {
return err
gistHost := strings.TrimSuffix(ghinstance.GistHost(hostname), "/")
if strings.HasPrefix(gistHost, "gist.") {
credHelperKeys = append(credHelperKeys, gitCredentialHelperKey(gistHost))
}
return run.PrepareCmd(configureCmd).Run()
var configErr error
for _, credHelperKey := range credHelperKeys {
if configErr != nil {
break
}
// first use a blank value to indicate to git we want to sever the chain of credential helpers
preConfigureCmd, err := git.GitCommand("config", "--global", "--replace-all", credHelperKey, "")
if err != nil {
configErr = err
break
}
if err = run.PrepareCmd(preConfigureCmd).Run(); err != nil {
configErr = err
break
}
// second configure the actual helper for this host
configureCmd, err := git.GitCommand(
"config", "--global", "--add",
credHelperKey,
fmt.Sprintf("!%s auth git-credential", shellQuote(flow.Executable)),
)
if err != nil {
configErr = err
} else {
configErr = run.PrepareCmd(configureCmd).Run()
}
}
return configErr
}
// clear previous cached credentials
@ -120,7 +142,8 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s
}
func gitCredentialHelperKey(hostname string) string {
return fmt.Sprintf("credential.https://%s.helper", hostname)
host := strings.TrimSuffix(ghinstance.HostPrefix(hostname), "/")
return fmt.Sprintf("credential.%s.helper", host)
}
func gitCredentialHelper(hostname string) (helper string, err error) {

View file

@ -22,10 +22,57 @@ func TestGitCredentialSetup_configureExisting(t *testing.T) {
}
}
func TestGitCredentialSetup_setOurs(t *testing.T) {
func TestGitCredentialsSetup_setOurs_GH(t *testing.T) {
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config --global credential\.`, 0, "", func(args []string) {
cs.Register(`git config --global --replace-all credential\.`, 0, "", func(args []string) {
if key := args[len(args)-2]; key != "credential.https://github.com.helper" {
t.Errorf("git config key was %q", key)
}
if val := args[len(args)-1]; val != "" {
t.Errorf("global credential helper configured to %q", val)
}
})
cs.Register(`git config --global --add credential\.`, 0, "", func(args []string) {
if key := args[len(args)-2]; key != "credential.https://github.com.helper" {
t.Errorf("git config key was %q", key)
}
if val := args[len(args)-1]; val != "!/path/to/gh auth git-credential" {
t.Errorf("global credential helper configured to %q", val)
}
})
cs.Register(`git config --global --replace-all credential\.`, 0, "", func(args []string) {
if key := args[len(args)-2]; key != "credential.https://gist.github.com.helper" {
t.Errorf("git config key was %q", key)
}
if val := args[len(args)-1]; val != "" {
t.Errorf("global credential helper configured to %q", val)
}
})
cs.Register(`git config --global --add credential\.`, 0, "", func(args []string) {
if key := args[len(args)-2]; key != "credential.https://gist.github.com.helper" {
t.Errorf("git config key was %q", key)
}
if val := args[len(args)-1]; val != "!/path/to/gh auth git-credential" {
t.Errorf("global credential helper configured to %q", val)
}
})
f := GitCredentialFlow{
Executable: "/path/to/gh",
helper: "",
}
if err := f.gitCredentialSetup("github.com", "monalisa", "PASSWD"); err != nil {
t.Errorf("GitCredentialSetup() error = %v", err)
}
}
func TestGitCredentialSetup_setOurs_nonGH(t *testing.T) {
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config --global --replace-all credential\.`, 0, "", func(args []string) {
if key := args[len(args)-2]; key != "credential.https://example.com.helper" {
t.Errorf("git config key was %q", key)
}

View file

@ -99,7 +99,7 @@ func Login(opts *LoginOptions) error {
var authMode int
if opts.Web {
authMode = 0
} else {
} else if opts.Interactive {
err := prompt.SurveyAskOne(&survey.Select{
Message: "How would you like to authenticate GitHub CLI?",
Options: []string{
@ -117,10 +117,11 @@ func Login(opts *LoginOptions) error {
if authMode == 0 {
var err error
authToken, err = authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", append(opts.Scopes, additionalScopes...))
authToken, err = authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", append(opts.Scopes, additionalScopes...), opts.Interactive)
if err != nil {
return fmt.Errorf("failed to authenticate via web browser: %w", err)
}
fmt.Fprintf(opts.IO.ErrOut, "%s Authentication complete.\n", cs.SuccessIcon())
userValidated = true
} else {
minimumScopes := append([]string{"repo", "read:org"}, additionalScopes...)

View file

@ -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)

View file

@ -32,19 +32,19 @@ type httpClient interface {
Do(*http.Request) (*http.Response, error)
}
func HasMinimumScopes(httpClient httpClient, hostname, authToken string) error {
func GetScopes(httpClient httpClient, hostname, authToken string) (string, error) {
apiEndpoint := ghinstance.RESTPrefix(hostname)
req, err := http.NewRequest("GET", apiEndpoint, nil)
if err != nil {
return err
return "", err
}
req.Header.Set("Authorization", "token "+authToken)
res, err := httpClient.Do(req)
if err != nil {
return err
return "", err
}
defer func() {
@ -55,10 +55,18 @@ func HasMinimumScopes(httpClient httpClient, hostname, authToken string) error {
}()
if res.StatusCode != 200 {
return api.HandleHTTPError(res)
return "", api.HandleHTTPError(res)
}
return res.Header.Get("X-Oauth-Scopes"), nil
}
func HasMinimumScopes(httpClient httpClient, hostname, authToken string) error {
scopesHeader, err := GetScopes(httpClient, hostname, authToken)
if err != nil {
return err
}
scopesHeader := res.Header.Get("X-Oauth-Scopes")
if scopesHeader == "" {
// if the token reports no scopes, assume that it's an integration token and give up on
// detecting its capabilities

View file

@ -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))

View file

@ -3,14 +3,19 @@ package browse
import (
"fmt"
"net/http"
"net/url"
"path"
"path/filepath"
"strconv"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/utils"
"github.com/spf13/cobra"
)
@ -19,14 +24,17 @@ type browser interface {
}
type BrowseOptions struct {
BaseRepo func() (ghrepo.Interface, error)
Browser browser
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Browser browser
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
PathFromRepoRoot func() string
GitClient gitClient
SelectorArg string
Branch string
CommitFlag bool
ProjectsFlag bool
SettingsFlag bool
WikiFlag bool
@ -35,9 +43,11 @@ type BrowseOptions struct {
func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Command {
opts := &BrowseOptions{
Browser: f.Browser,
HttpClient: f.HttpClient,
IO: f.IOStreams,
Browser: f.Browser,
HttpClient: f.HttpClient,
IO: f.IOStreams,
PathFromRepoRoot: git.PathFromRepoRoot,
GitClient: &localGitClient{},
}
cmd := &cobra.Command{
@ -80,14 +90,18 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
}
if err := cmdutil.MutuallyExclusive(
"specify only one of `--branch`, `--projects`, `--wiki`, or `--settings`",
"specify only one of `--branch`, `--commit`, `--projects`, `--wiki`, or `--settings`",
opts.Branch != "",
opts.CommitFlag,
opts.WikiFlag,
opts.SettingsFlag,
opts.ProjectsFlag,
); err != nil {
return err
}
if cmd.Flags().Changed("repo") {
opts.GitClient = &remoteGitClient{opts.BaseRepo, opts.HttpClient}
}
if runF != nil {
return runF(opts)
@ -101,6 +115,7 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Open repository wiki")
cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Open repository settings")
cmd.Flags().BoolVarP(&opts.NoBrowserFlag, "no-browser", "n", false, "Print destination URL instead of opening the browser")
cmd.Flags().BoolVarP(&opts.CommitFlag, "commit", "c", false, "Open the last commit")
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Select another branch by passing in the branch name")
return cmd
@ -112,89 +127,157 @@ func runBrowse(opts *BrowseOptions) error {
return fmt.Errorf("unable to determine base repository: %w", err)
}
httpClient, err := opts.HttpClient()
if err != nil {
return fmt.Errorf("unable to create an http client: %w", err)
if opts.CommitFlag {
commit, err := opts.GitClient.LastCommit()
if err != nil {
return err
}
opts.Branch = commit.Sha
}
url := ghrepo.GenerateRepoURL(baseRepo, "")
if opts.SelectorArg == "" {
if opts.ProjectsFlag {
url += "/projects"
}
if opts.SettingsFlag {
url += "/settings"
}
if opts.WikiFlag {
url += "/wiki"
}
if opts.Branch != "" {
url += "/tree/" + opts.Branch + "/"
}
} else {
if isNumber(opts.SelectorArg) {
url += "/issues/" + opts.SelectorArg
} else {
fileArg, err := parseFileArg(opts.SelectorArg)
if err != nil {
return err
}
if opts.Branch != "" {
url += "/tree/" + opts.Branch + "/"
} else {
apiClient := api.NewClientFromHTTP(httpClient)
branchName, err := api.RepoDefaultBranch(apiClient, baseRepo)
if err != nil {
return err
}
url += "/tree/" + branchName + "/"
}
url += fileArg
}
section, err := parseSection(baseRepo, opts)
if err != nil {
return err
}
url := ghrepo.GenerateRepoURL(baseRepo, "%s", section)
if opts.NoBrowserFlag {
fmt.Fprintf(opts.IO.Out, "%s\n", url)
return nil
} else {
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.Out, "now opening %s in browser\n", url)
}
return opts.Browser.Browse(url)
_, err := fmt.Fprintln(opts.IO.Out, url)
return err
}
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(url))
}
return opts.Browser.Browse(url)
}
func parseFileArg(fileArg string) (string, error) {
arr := strings.Split(fileArg, ":")
if len(arr) > 2 {
return "", fmt.Errorf("invalid use of colon\nUse 'gh browse --help' for more information about browse\n")
func parseSection(baseRepo ghrepo.Interface, opts *BrowseOptions) (string, error) {
if opts.SelectorArg == "" {
if opts.ProjectsFlag {
return "projects", nil
} else if opts.SettingsFlag {
return "settings", nil
} else if opts.WikiFlag {
return "wiki", nil
} else if opts.Branch == "" {
return "", nil
}
}
if len(arr) > 1 {
out := arr[0] + "#L"
lineRange := strings.Split(arr[1], "-")
if len(lineRange) > 0 {
if !isNumber(lineRange[0]) {
return "", fmt.Errorf("invalid line number after colon\nUse 'gh browse --help' for more information about browse\n")
}
out += lineRange[0]
}
if len(lineRange) > 1 {
if !isNumber(lineRange[1]) {
return "", fmt.Errorf("invalid line range after colon\nUse 'gh browse --help' for more information about browse\n")
}
out += "-L" + lineRange[1]
}
return out, nil
if isNumber(opts.SelectorArg) {
return fmt.Sprintf("issues/%s", opts.SelectorArg), nil
}
return arr[0], nil
filePath, rangeStart, rangeEnd, err := parseFile(*opts, opts.SelectorArg)
if err != nil {
return "", err
}
branchName := opts.Branch
if branchName == "" {
httpClient, err := opts.HttpClient()
if err != nil {
return "", err
}
apiClient := api.NewClientFromHTTP(httpClient)
branchName, err = api.RepoDefaultBranch(apiClient, baseRepo)
if err != nil {
return "", fmt.Errorf("error determining the default branch: %w", err)
}
}
if rangeStart > 0 {
var rangeFragment string
if rangeEnd > 0 && rangeStart != rangeEnd {
rangeFragment = fmt.Sprintf("L%d-L%d", rangeStart, rangeEnd)
} else {
rangeFragment = fmt.Sprintf("L%d", rangeStart)
}
return fmt.Sprintf("blob/%s/%s?plain=1#%s", escapePath(branchName), escapePath(filePath), rangeFragment), nil
}
return strings.TrimSuffix(fmt.Sprintf("tree/%s/%s", escapePath(branchName), escapePath(filePath)), "/"), nil
}
// escapePath URL-encodes special characters but leaves slashes unchanged
func escapePath(p string) string {
return strings.ReplaceAll(url.PathEscape(p), "%2F", "/")
}
func parseFile(opts BrowseOptions, f string) (p string, start int, end int, err error) {
if f == "" {
return
}
parts := strings.SplitN(f, ":", 3)
if len(parts) > 2 {
err = fmt.Errorf("invalid file argument: %q", f)
return
}
p = filepath.ToSlash(parts[0])
if !path.IsAbs(p) {
p = path.Join(opts.PathFromRepoRoot(), p)
if p == "." || strings.HasPrefix(p, "..") {
p = ""
}
}
if len(parts) < 2 {
return
}
if idx := strings.IndexRune(parts[1], '-'); idx >= 0 {
start, err = strconv.Atoi(parts[1][:idx])
if err != nil {
err = fmt.Errorf("invalid file argument: %q", f)
return
}
end, err = strconv.Atoi(parts[1][idx+1:])
if err != nil {
err = fmt.Errorf("invalid file argument: %q", f)
}
return
}
start, err = strconv.Atoi(parts[1])
if err != nil {
err = fmt.Errorf("invalid file argument: %q", f)
}
end = start
return
}
func isNumber(arg string) bool {
_, err := strconv.Atoi(arg)
return err == nil
}
// gitClient is used to implement functions that can be performed on both local and remote git repositories
type gitClient interface {
LastCommit() (*git.Commit, error)
}
type localGitClient struct{}
type remoteGitClient struct {
repo func() (ghrepo.Interface, error)
httpClient func() (*http.Client, error)
}
func (gc *localGitClient) LastCommit() (*git.Commit, error) { return git.LastCommit() }
func (gc *remoteGitClient) LastCommit() (*git.Commit, error) {
httpClient, err := gc.httpClient()
if err != nil {
return nil, err
}
repo, err := gc.repo()
if err != nil {
return nil, err
}
commit, err := api.LastCommit(api.NewClientFromHTTP(httpClient), repo)
if err != nil {
return nil, err
}
return &git.Commit{Sha: commit.OID}, nil
}

View file

@ -2,9 +2,13 @@ package browse
import (
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
@ -102,6 +106,14 @@ func TestNewCmdBrowse(t *testing.T) {
cli: "main.go main.go",
wantsErr: true,
},
{
name: "last commit flag",
cli: "-c",
wants: BrowseOptions{
CommitFlag: true,
},
wantsErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -114,6 +126,8 @@ func TestNewCmdBrowse(t *testing.T) {
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
cmd.SetArgs(argv)
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
if tt.wantsErr {
@ -129,11 +143,29 @@ func TestNewCmdBrowse(t *testing.T) {
assert.Equal(t, tt.wants.WikiFlag, opts.WikiFlag)
assert.Equal(t, tt.wants.NoBrowserFlag, opts.NoBrowserFlag)
assert.Equal(t, tt.wants.SettingsFlag, opts.SettingsFlag)
assert.Equal(t, tt.wants.CommitFlag, opts.CommitFlag)
})
}
}
func setGitDir(t *testing.T, dir string) {
// taken from git_test.go
old_GIT_DIR := os.Getenv("GIT_DIR")
os.Setenv("GIT_DIR", dir)
t.Cleanup(func() {
os.Setenv("GIT_DIR", old_GIT_DIR)
})
}
type testGitClient struct{}
func (gc *testGitClient) LastCommit() (*git.Commit, error) {
return &git.Commit{Sha: "6f1a2405cace1633d89a79c74c65f22fe78f9659"}, nil
}
func Test_runBrowse(t *testing.T) {
s := string(os.PathSeparator)
setGitDir(t, "../../../git/fixtures/simple.git")
tests := []struct {
name string
opts BrowseOptions
@ -195,7 +227,7 @@ func Test_runBrowse(t *testing.T) {
Branch: "trunk",
},
baseRepo: ghrepo.New("jlsestak", "CouldNotThinkOfARepoName"),
expectedURL: "https://github.com/jlsestak/CouldNotThinkOfARepoName/tree/trunk/",
expectedURL: "https://github.com/jlsestak/CouldNotThinkOfARepoName/tree/trunk",
},
{
name: "branch flag with file",
@ -206,6 +238,35 @@ func Test_runBrowse(t *testing.T) {
baseRepo: ghrepo.New("bchadwic", "LedZeppelinIV"),
expectedURL: "https://github.com/bchadwic/LedZeppelinIV/tree/trunk/main.go",
},
{
name: "branch flag within dir",
opts: BrowseOptions{
Branch: "feature-123",
PathFromRepoRoot: func() string { return "pkg/dir" },
},
baseRepo: ghrepo.New("bstnc", "yeepers"),
expectedURL: "https://github.com/bstnc/yeepers/tree/feature-123",
},
{
name: "branch flag within dir with .",
opts: BrowseOptions{
Branch: "feature-123",
SelectorArg: ".",
PathFromRepoRoot: func() string { return "pkg/dir" },
},
baseRepo: ghrepo.New("bstnc", "yeepers"),
expectedURL: "https://github.com/bstnc/yeepers/tree/feature-123/pkg/dir",
},
{
name: "branch flag within dir with dir",
opts: BrowseOptions{
Branch: "feature-123",
SelectorArg: "inner/more",
PathFromRepoRoot: func() string { return "pkg/dir" },
},
baseRepo: ghrepo.New("bstnc", "yeepers"),
expectedURL: "https://github.com/bstnc/yeepers/tree/feature-123/pkg/dir/inner/more",
},
{
name: "file with line number",
opts: BrowseOptions{
@ -213,7 +274,7 @@ func Test_runBrowse(t *testing.T) {
},
baseRepo: ghrepo.New("ravocean", "angur"),
defaultBranch: "trunk",
expectedURL: "https://github.com/ravocean/angur/tree/trunk/path/to/file.txt#L32",
expectedURL: "https://github.com/ravocean/angur/blob/trunk/path/to/file.txt?plain=1#L32",
},
{
name: "file with line range",
@ -222,12 +283,37 @@ func Test_runBrowse(t *testing.T) {
},
baseRepo: ghrepo.New("ravocean", "angur"),
defaultBranch: "trunk",
expectedURL: "https://github.com/ravocean/angur/tree/trunk/path/to/file.txt#L32-L40",
expectedURL: "https://github.com/ravocean/angur/blob/trunk/path/to/file.txt?plain=1#L32-L40",
},
{
name: "invalid default branch",
opts: BrowseOptions{
SelectorArg: "chocolate-pecan-pie.txt",
},
baseRepo: ghrepo.New("andrewhsu", "recipies"),
defaultBranch: "",
wantsErr: true,
},
{
name: "file with invalid line number after colon",
opts: BrowseOptions{
SelectorArg: "laptime-notes.txt:w-9",
},
baseRepo: ghrepo.New("andrewhsu", "sonoma-raceway"),
wantsErr: true,
},
{
name: "file with invalid file format",
opts: BrowseOptions{
SelectorArg: "path/to/file.txt:32:32",
},
baseRepo: ghrepo.New("ttran112", "ttrain211"),
wantsErr: true,
},
{
name: "file with invalid line number",
opts: BrowseOptions{
SelectorArg: "path/to/file.txt:32:32",
SelectorArg: "path/to/file.txt:32a",
},
baseRepo: ghrepo.New("ttran112", "ttrain211"),
wantsErr: true,
@ -258,7 +344,7 @@ func Test_runBrowse(t *testing.T) {
},
baseRepo: ghrepo.New("github", "ThankYouGitHub"),
wantsErr: false,
expectedURL: "https://github.com/github/ThankYouGitHub/tree/first-browse-pull/browse.go#L32",
expectedURL: "https://github.com/github/ThankYouGitHub/blob/first-browse-pull/browse.go?plain=1#L32",
},
{
name: "no browser with branch file and line number",
@ -269,7 +355,64 @@ func Test_runBrowse(t *testing.T) {
},
baseRepo: ghrepo.New("mislav", "will_paginate"),
wantsErr: false,
expectedURL: "https://github.com/mislav/will_paginate/tree/3-0-stable/init.rb#L6",
expectedURL: "https://github.com/mislav/will_paginate/blob/3-0-stable/init.rb?plain=1#L6",
},
{
name: "open last commit",
opts: BrowseOptions{
CommitFlag: true,
GitClient: &testGitClient{},
},
baseRepo: ghrepo.New("vilmibm", "gh-user-status"),
wantsErr: false,
expectedURL: "https://github.com/vilmibm/gh-user-status/tree/6f1a2405cace1633d89a79c74c65f22fe78f9659",
},
{
name: "open last commit with a file",
opts: BrowseOptions{
CommitFlag: true,
SelectorArg: "main.go",
GitClient: &testGitClient{},
},
baseRepo: ghrepo.New("vilmibm", "gh-user-status"),
wantsErr: false,
expectedURL: "https://github.com/vilmibm/gh-user-status/tree/6f1a2405cace1633d89a79c74c65f22fe78f9659/main.go",
},
{
name: "relative path from browse_test.go",
opts: BrowseOptions{
SelectorArg: filepath.Join(".", "browse_test.go"),
PathFromRepoRoot: func() string {
return "pkg/cmd/browse/"
},
},
baseRepo: ghrepo.New("bchadwic", "gh-graph"),
defaultBranch: "trunk",
expectedURL: "https://github.com/bchadwic/gh-graph/tree/trunk/pkg/cmd/browse/browse_test.go",
wantsErr: false,
},
{
name: "relative path to file in parent folder from browse_test.go",
opts: BrowseOptions{
SelectorArg: ".." + s + "pr",
PathFromRepoRoot: func() string {
return "pkg/cmd/browse/"
},
},
baseRepo: ghrepo.New("bchadwic", "gh-graph"),
defaultBranch: "trunk",
expectedURL: "https://github.com/bchadwic/gh-graph/tree/trunk/pkg/cmd/pr",
wantsErr: false,
},
{
name: "use special characters in selector arg",
opts: BrowseOptions{
SelectorArg: "?=hello world/ *:23-44",
Branch: "branch/with spaces?",
},
baseRepo: ghrepo.New("bchadwic", "test"),
expectedURL: "https://github.com/bchadwic/test/blob/branch/with%20spaces%3F/%3F=hello%20world/%20%2A?plain=1#L23-L44",
wantsErr: false,
},
}
@ -293,6 +436,9 @@ func Test_runBrowse(t *testing.T) {
return &http.Client{Transport: &reg}, nil
}
opts.Browser = &browser
if opts.PathFromRepoRoot == nil {
opts.PathFromRepoRoot = git.PathFromRepoRoot
}
err := runBrowse(&opts)
if tt.wantsErr {
@ -314,40 +460,102 @@ func Test_runBrowse(t *testing.T) {
}
}
func Test_parseFileArg(t *testing.T) {
func Test_parsePathFromFileArg(t *testing.T) {
tests := []struct {
name string
arg string
errorExpected bool
expectedFileArg string
stderrExpected string
name string
currentDir string
fileArg string
expectedPath string
}{
{
name: "non line number",
arg: "main.go",
errorExpected: false,
expectedFileArg: "main.go",
name: "empty paths",
currentDir: "",
fileArg: "",
expectedPath: "",
},
{
name: "line number",
arg: "main.go:32",
errorExpected: false,
expectedFileArg: "main.go#L32",
name: "root directory",
currentDir: "",
fileArg: ".",
expectedPath: "",
},
{
name: "non line number error",
arg: "ma:in.go",
errorExpected: true,
stderrExpected: "invalid line number after colon\nUse 'gh browse --help' for more information about browse\n",
name: "relative path",
currentDir: "",
fileArg: filepath.FromSlash("foo/bar.py"),
expectedPath: "foo/bar.py",
},
{
name: "go to parent folder",
currentDir: "pkg/cmd/browse/",
fileArg: filepath.FromSlash("../"),
expectedPath: "pkg/cmd",
},
{
name: "current folder",
currentDir: "pkg/cmd/browse/",
fileArg: ".",
expectedPath: "pkg/cmd/browse",
},
{
name: "current folder (alternative)",
currentDir: "pkg/cmd/browse/",
fileArg: filepath.FromSlash("./"),
expectedPath: "pkg/cmd/browse",
},
{
name: "file that starts with '.'",
currentDir: "pkg/cmd/browse/",
fileArg: ".gitignore",
expectedPath: "pkg/cmd/browse/.gitignore",
},
{
name: "file in current folder",
currentDir: "pkg/cmd/browse/",
fileArg: filepath.Join(".", "browse.go"),
expectedPath: "pkg/cmd/browse/browse.go",
},
{
name: "file within parent folder",
currentDir: "pkg/cmd/browse/",
fileArg: filepath.Join("..", "browse.go"),
expectedPath: "pkg/cmd/browse.go",
},
{
name: "file within parent folder uncleaned",
currentDir: "pkg/cmd/browse/",
fileArg: filepath.FromSlash(".././//browse.go"),
expectedPath: "pkg/cmd/browse.go",
},
{
name: "different path from root directory",
currentDir: "pkg/cmd/browse/",
fileArg: filepath.Join("..", "..", "..", "internal/build/build.go"),
expectedPath: "internal/build/build.go",
},
{
name: "go out of repository",
currentDir: "pkg/cmd/browse/",
fileArg: filepath.FromSlash("../../../../../../"),
expectedPath: "",
},
{
name: "go to root of repository",
currentDir: "pkg/cmd/browse/",
fileArg: filepath.Join("../../../"),
expectedPath: "",
},
{
name: "empty fileArg",
fileArg: "",
expectedPath: "",
},
}
for _, tt := range tests {
fileArg, err := parseFileArg(tt.arg)
if tt.errorExpected {
assert.Equal(t, err.Error(), tt.stderrExpected)
} else {
assert.Equal(t, err, nil)
assert.Equal(t, tt.expectedFileArg, fileArg)
}
path, _, _, _ := parseFile(BrowseOptions{
PathFromRepoRoot: func() string {
return tt.currentDir
}}, tt.fileArg)
assert.Equal(t, tt.expectedPath, path, tt.name)
}
}

66
pkg/cmd/codespace/code.go Normal file
View file

@ -0,0 +1,66 @@
package codespace
import (
"context"
"fmt"
"io/ioutil"
"net/url"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
)
type browser interface {
Browse(string) error
}
func newCodeCmd(app *App) *cobra.Command {
var (
codespace string
useInsiders bool
)
codeCmd := &cobra.Command{
Use: "code",
Short: "Open a codespace in Visual Studio Code",
Args: noArgsConstraint,
RunE: func(cmd *cobra.Command, args []string) error {
b := cmdutil.NewBrowser("", ioutil.Discard, app.io.ErrOut)
return app.VSCode(cmd.Context(), b, codespace, useInsiders)
},
}
codeCmd.Flags().StringVarP(&codespace, "codespace", "c", "", "Name of the codespace")
codeCmd.Flags().BoolVar(&useInsiders, "insiders", false, "Use the insiders version of Visual Studio Code")
return codeCmd
}
// VSCode opens a codespace in the local VS VSCode application.
func (a *App) VSCode(ctx context.Context, browser browser, codespaceName string, useInsiders bool) error {
if codespaceName == "" {
codespace, err := chooseCodespace(ctx, a.apiClient)
if err != nil {
if err == errNoCodespaces {
return err
}
return fmt.Errorf("error choosing codespace: %w", err)
}
codespaceName = codespace.Name
}
url := vscodeProtocolURL(codespaceName, useInsiders)
if err := browser.Browse(url); err != nil {
return fmt.Errorf("error opening Visual Studio Code: %w", err)
}
return nil
}
func vscodeProtocolURL(codespaceName string, useInsiders bool) string {
application := "vscode"
if useInsiders {
application = "vscode-insiders"
}
return fmt.Sprintf("%s://github.codespaces/connect?name=%s", application, url.QueryEscape(codespaceName))
}

View file

@ -0,0 +1,50 @@
package codespace
import (
"context"
"testing"
"github.com/cli/cli/v2/pkg/cmdutil"
)
func TestApp_VSCode(t *testing.T) {
type args struct {
codespaceName string
useInsiders bool
}
tests := []struct {
name string
args args
wantErr bool
wantURL string
}{
{
name: "open VS Code",
args: args{
codespaceName: "monalisa-cli-cli-abcdef",
useInsiders: false,
},
wantErr: false,
wantURL: "vscode://github.codespaces/connect?name=monalisa-cli-cli-abcdef",
},
{
name: "open VS Code Insiders",
args: args{
codespaceName: "monalisa-cli-cli-abcdef",
useInsiders: true,
},
wantErr: false,
wantURL: "vscode-insiders://github.codespaces/connect?name=monalisa-cli-cli-abcdef",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := &cmdutil.TestBrowser{}
a := &App{}
if err := a.VSCode(context.Background(), b, tt.args.codespaceName, tt.args.useInsiders); (err != nil) != tt.wantErr {
t.Errorf("App.VSCode() error = %v, wantErr %v", err, tt.wantErr)
}
b.Verify(t, tt.wantURL)
})
}
}

291
pkg/cmd/codespace/common.go Normal file
View file

@ -0,0 +1,291 @@
package codespace
// This file defines functions common to the entire codespace command set.
import (
"context"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"sort"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
"golang.org/x/term"
)
type executable interface {
Executable() string
}
type App struct {
io *iostreams.IOStreams
apiClient apiClient
errLogger *log.Logger
executable executable
}
func NewApp(io *iostreams.IOStreams, exe executable, apiClient apiClient) *App {
errLogger := log.New(io.ErrOut, "", 0)
return &App{
io: io,
apiClient: apiClient,
errLogger: errLogger,
executable: exe,
}
}
// StartProgressIndicatorWithLabel starts a progress indicator with a message.
func (a *App) StartProgressIndicatorWithLabel(s string) {
a.io.StartProgressIndicatorWithLabel(s)
}
// StopProgressIndicator stops the progress indicator.
func (a *App) StopProgressIndicator() {
a.io.StopProgressIndicator()
}
//go:generate moq -fmt goimports -rm -skip-ensure -out mock_api.go . apiClient
type apiClient interface {
GetUser(ctx context.Context) (*api.User, error)
GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error)
ListCodespaces(ctx context.Context, limit int) ([]*api.Codespace, error)
DeleteCodespace(ctx context.Context, name string) error
StartCodespace(ctx context.Context, name string) error
StopCodespace(ctx context.Context, name string) error
CreateCodespace(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error)
GetRepository(ctx context.Context, nwo string) (*api.Repository, error)
AuthorizedKeys(ctx context.Context, user string) ([]byte, error)
GetCodespaceRegionLocation(ctx context.Context) (string, error)
GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error)
GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
}
var errNoCodespaces = errors.New("you have no codespaces")
func chooseCodespace(ctx context.Context, apiClient apiClient) (*api.Codespace, error) {
codespaces, err := apiClient.ListCodespaces(ctx, -1)
if err != nil {
return nil, fmt.Errorf("error getting codespaces: %w", err)
}
return chooseCodespaceFromList(ctx, codespaces)
}
// chooseCodespaceFromList returns the selected codespace from the list,
// or an error if there are no codespaces.
func chooseCodespaceFromList(ctx context.Context, codespaces []*api.Codespace) (*api.Codespace, error) {
if len(codespaces) == 0 {
return nil, errNoCodespaces
}
sort.Slice(codespaces, func(i, j int) bool {
return codespaces[i].CreatedAt > codespaces[j].CreatedAt
})
type codespaceWithIndex struct {
cs codespace
idx int
}
namesWithConflict := make(map[string]bool)
codespacesByName := make(map[string]codespaceWithIndex)
codespacesNames := make([]string, 0, len(codespaces))
for _, apiCodespace := range codespaces {
cs := codespace{apiCodespace}
csName := cs.displayName(false, false)
displayNameWithGitStatus := cs.displayName(false, true)
_, hasExistingConflict := namesWithConflict[csName]
if seenCodespace, ok := codespacesByName[csName]; ok || hasExistingConflict {
// There is an existing codespace on the repo and branch.
// We need to disambiguate by adding the codespace name
// to the existing entry and the one we are processing now.
if !hasExistingConflict {
fullDisplayName := seenCodespace.cs.displayName(true, false)
fullDisplayNameWithGitStatus := seenCodespace.cs.displayName(true, true)
codespacesByName[fullDisplayName] = codespaceWithIndex{seenCodespace.cs, seenCodespace.idx}
codespacesNames[seenCodespace.idx] = fullDisplayNameWithGitStatus
delete(codespacesByName, csName) // delete the existing map entry with old name
// All other codespaces with the same name should update
// to their specific name, this tracks conflicting names going forward
namesWithConflict[csName] = true
}
// update this codespace names to include the name to disambiguate
csName = cs.displayName(true, false)
displayNameWithGitStatus = cs.displayName(true, true)
}
codespacesByName[csName] = codespaceWithIndex{cs, len(codespacesNames)}
codespacesNames = append(codespacesNames, displayNameWithGitStatus)
}
csSurvey := []*survey.Question{
{
Name: "codespace",
Prompt: &survey.Select{
Message: "Choose codespace:",
Options: codespacesNames,
Default: codespacesNames[0],
},
Validate: survey.Required,
},
}
var answers struct {
Codespace string
}
if err := ask(csSurvey, &answers); err != nil {
return nil, fmt.Errorf("error getting answers: %w", err)
}
// Codespaces are indexed without the git status included as compared
// to how it is displayed in the prompt, so the git status symbol needs
// cleaning up in case it is included.
selectedCodespace := strings.Replace(answers.Codespace, gitStatusDirty, "", -1)
return codespacesByName[selectedCodespace].cs.Codespace, nil
}
// getOrChooseCodespace prompts the user to choose a codespace if the codespaceName is empty.
// It then fetches the codespace record with full connection details.
// TODO(josebalius): accept a progress indicator or *App and show progress when fetching.
func getOrChooseCodespace(ctx context.Context, apiClient apiClient, codespaceName string) (codespace *api.Codespace, err error) {
if codespaceName == "" {
codespace, err = chooseCodespace(ctx, apiClient)
if err != nil {
if err == errNoCodespaces {
return nil, err
}
return nil, fmt.Errorf("choosing codespace: %w", err)
}
} else {
codespace, err = apiClient.GetCodespace(ctx, codespaceName, true)
if err != nil {
return nil, fmt.Errorf("getting full codespace details: %w", err)
}
}
return codespace, nil
}
func safeClose(closer io.Closer, err *error) {
if closeErr := closer.Close(); *err == nil {
*err = closeErr
}
}
// hasTTY indicates whether the process connected to a terminal.
// It is not portable to assume stdin/stdout are fds 0 and 1.
var hasTTY = term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
// ask asks survey questions on the terminal, using standard options.
// It fails unless hasTTY, but ideally callers should avoid calling it in that case.
func ask(qs []*survey.Question, response interface{}) error {
if !hasTTY {
return fmt.Errorf("no terminal")
}
err := survey.Ask(qs, response, survey.WithShowCursor(true))
// The survey package temporarily clears the terminal's ISIG mode bit
// (see tcsetattr(3)) so the QUIT button (Ctrl-C) is reported as
// ASCII \x03 (ETX) instead of delivering SIGINT to the application.
// So we have to serve ourselves the SIGINT.
//
// https://github.com/AlecAivazis/survey/#why-isnt-ctrl-c-working
if err == terminal.InterruptErr {
self, _ := os.FindProcess(os.Getpid())
_ = self.Signal(os.Interrupt) // assumes POSIX
// Suspend the goroutine, to avoid a race between
// return from main and async delivery of INT signal.
select {}
}
return err
}
// checkAuthorizedKeys reports an error if the user has not registered any SSH keys;
// see https://github.com/cli/cli/v2/issues/166#issuecomment-921769703.
// The check is not required for security but it improves the error message.
func checkAuthorizedKeys(ctx context.Context, client apiClient) error {
user, err := client.GetUser(ctx)
if err != nil {
return fmt.Errorf("error getting user: %w", err)
}
keys, err := client.AuthorizedKeys(ctx, user.Login)
if err != nil {
return fmt.Errorf("failed to read GitHub-authorized SSH keys for %s: %w", user, err)
}
if len(keys) == 0 {
return fmt.Errorf("user %s has no GitHub-authorized SSH keys", user)
}
return nil // success
}
var ErrTooManyArgs = errors.New("the command accepts no arguments")
func noArgsConstraint(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return ErrTooManyArgs
}
return nil
}
func noopLogger() *log.Logger {
return log.New(ioutil.Discard, "", 0)
}
type codespace struct {
*api.Codespace
}
// displayName returns the repository nwo and branch.
// If includeName is true, the name of the codespace is included.
// If includeGitStatus is true, the branch will include a star if
// the codespace has unsaved changes.
func (c codespace) displayName(includeName, includeGitStatus bool) string {
branch := c.GitStatus.Ref
if includeGitStatus {
branch = c.branchWithGitStatus()
}
if includeName {
return fmt.Sprintf(
"%s: %s [%s]", c.Repository.FullName, branch, c.Name,
)
}
return c.Repository.FullName + ": " + branch
}
// gitStatusDirty represents an unsaved changes status.
const gitStatusDirty = "*"
// branchWithGitStatus returns the branch with a star
// if the branch is currently being worked on.
func (c codespace) branchWithGitStatus() string {
if c.hasUnsavedChanges() {
return c.GitStatus.Ref + gitStatusDirty
}
return c.GitStatus.Ref
}
// hasUnsavedChanges returns whether the environment has
// unsaved changes.
func (c codespace) hasUnsavedChanges() bool {
return c.GitStatus.HasUncommitedChanges || c.GitStatus.HasUnpushedChanges
}
// running returns whether the codespace environment is running.
func (c codespace) running() bool {
return c.State == api.CodespaceStateAvailable
}

277
pkg/cmd/codespace/create.go Normal file
View file

@ -0,0 +1,277 @@
package codespace
import (
"context"
"errors"
"fmt"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/v2/internal/codespaces"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/spf13/cobra"
)
type createOptions struct {
repo string
branch string
machine string
showStatus bool
idleTimeout time.Duration
}
func newCreateCmd(app *App) *cobra.Command {
opts := createOptions{}
createCmd := &cobra.Command{
Use: "create",
Short: "Create a codespace",
Args: noArgsConstraint,
RunE: func(cmd *cobra.Command, args []string) error {
return app.Create(cmd.Context(), opts)
},
}
createCmd.Flags().StringVarP(&opts.repo, "repo", "r", "", "repository name with owner: user/repo")
createCmd.Flags().StringVarP(&opts.branch, "branch", "b", "", "repository branch")
createCmd.Flags().StringVarP(&opts.machine, "machine", "m", "", "hardware specifications for the VM")
createCmd.Flags().BoolVarP(&opts.showStatus, "status", "s", false, "show status of post-create command and dotfiles")
createCmd.Flags().DurationVar(&opts.idleTimeout, "idle-timeout", 0, "allowed inactivity before codespace is stopped, e.g. \"10m\", \"1h\"")
return createCmd
}
// Create creates a new Codespace
func (a *App) Create(ctx context.Context, opts createOptions) error {
locationCh := getLocation(ctx, a.apiClient)
userInputs := struct {
Repository string
Branch string
}{
Repository: opts.repo,
Branch: opts.branch,
}
if userInputs.Repository == "" {
branchPrompt := "Branch (leave blank for default branch):"
if userInputs.Branch != "" {
branchPrompt = "Branch:"
}
questions := []*survey.Question{
{
Name: "repository",
Prompt: &survey.Input{Message: "Repository:"},
Validate: survey.Required,
},
{
Name: "branch",
Prompt: &survey.Input{
Message: branchPrompt,
Default: userInputs.Branch,
},
},
}
if err := ask(questions, &userInputs); err != nil {
return fmt.Errorf("failed to prompt: %w", err)
}
}
a.StartProgressIndicatorWithLabel("Fetching repository")
repository, err := a.apiClient.GetRepository(ctx, userInputs.Repository)
a.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error getting repository: %w", err)
}
branch := userInputs.Branch
if branch == "" {
branch = repository.DefaultBranch
}
locationResult := <-locationCh
if locationResult.Err != nil {
return fmt.Errorf("error getting codespace region location: %w", locationResult.Err)
}
machine, err := getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, locationResult.Location)
if err != nil {
return fmt.Errorf("error getting machine type: %w", err)
}
if machine == "" {
return errors.New("there are no available machine types for this repository")
}
a.StartProgressIndicatorWithLabel("Creating codespace")
codespace, err := a.apiClient.CreateCodespace(ctx, &api.CreateCodespaceParams{
RepositoryID: repository.ID,
Branch: branch,
Machine: machine,
Location: locationResult.Location,
IdleTimeoutMinutes: int(opts.idleTimeout.Minutes()),
})
a.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error creating codespace: %w", err)
}
if opts.showStatus {
if err := a.showStatus(ctx, codespace); err != nil {
return fmt.Errorf("show status: %w", err)
}
}
fmt.Fprintln(a.io.Out, codespace.Name)
return nil
}
// showStatus polls the codespace for a list of post create states and their status. It will keep polling
// until all states have finished. Once all states have finished, we poll once more to check if any new
// states have been introduced and stop polling otherwise.
func (a *App) showStatus(ctx context.Context, codespace *api.Codespace) error {
var (
lastState codespaces.PostCreateState
breakNextState bool
)
finishedStates := make(map[string]bool)
ctx, stopPolling := context.WithCancel(ctx)
defer stopPolling()
poller := func(states []codespaces.PostCreateState) {
var inProgress bool
for _, state := range states {
if _, found := finishedStates[state.Name]; found {
continue // skip this state as we've processed it already
}
if state.Name != lastState.Name {
a.StartProgressIndicatorWithLabel(state.Name)
if state.Status == codespaces.PostCreateStateRunning {
inProgress = true
lastState = state
break
}
finishedStates[state.Name] = true
a.StopProgressIndicator()
} else {
if state.Status == codespaces.PostCreateStateRunning {
inProgress = true
break
}
finishedStates[state.Name] = true
a.StopProgressIndicator()
lastState = codespaces.PostCreateState{} // reset the value
}
}
if !inProgress {
if breakNextState {
stopPolling()
return
}
breakNextState = true
}
}
err := codespaces.PollPostCreateStates(ctx, a, a.apiClient, codespace, poller)
if err != nil {
if errors.Is(err, context.Canceled) && breakNextState {
return nil // we cancelled the context to stop polling, we can ignore the error
}
return fmt.Errorf("failed to poll state changes from codespace: %w", err)
}
return nil
}
type locationResult struct {
Location string
Err error
}
// getLocation fetches the closest Codespace datacenter region/location to the user.
func getLocation(ctx context.Context, apiClient apiClient) <-chan locationResult {
ch := make(chan locationResult, 1)
go func() {
location, err := apiClient.GetCodespaceRegionLocation(ctx)
ch <- locationResult{location, err}
}()
return ch
}
// getMachineName prompts the user to select the machine type, or validates the machine if non-empty.
func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machine, branch, location string) (string, error) {
machines, err := apiClient.GetCodespacesMachines(ctx, repoID, branch, location)
if err != nil {
return "", fmt.Errorf("error requesting machine instance types: %w", err)
}
// if user supplied a machine type, it must be valid
// if no machine type was supplied, we don't error if there are no machine types for the current repo
if machine != "" {
for _, m := range machines {
if machine == m.Name {
return machine, nil
}
}
availableMachines := make([]string, len(machines))
for i := 0; i < len(machines); i++ {
availableMachines[i] = machines[i].Name
}
return "", fmt.Errorf("there is no such machine for the repository: %s\nAvailable machines: %v", machine, availableMachines)
} else if len(machines) == 0 {
return "", nil
}
if len(machines) == 1 {
// VS Code does not prompt for machine if there is only one, this makes us consistent with that behavior
return machines[0].Name, nil
}
machineNames := make([]string, 0, len(machines))
machineByName := make(map[string]*api.Machine)
for _, m := range machines {
machineName := buildDisplayName(m.DisplayName, m.PrebuildAvailability)
machineNames = append(machineNames, machineName)
machineByName[machineName] = m
}
machineSurvey := []*survey.Question{
{
Name: "machine",
Prompt: &survey.Select{
Message: "Choose Machine Type:",
Options: machineNames,
Default: machineNames[0],
},
Validate: survey.Required,
},
}
var machineAnswers struct{ Machine string }
if err := ask(machineSurvey, &machineAnswers); err != nil {
return "", fmt.Errorf("error getting machine: %w", err)
}
selectedMachine := machineByName[machineAnswers.Machine]
return selectedMachine.Name, nil
}
// buildDisplayName returns display name to be used in the machine survey prompt.
func buildDisplayName(displayName string, prebuildAvailability string) string {
prebuildText := ""
if prebuildAvailability == "blob" || prebuildAvailability == "pool" {
prebuildText = " (Prebuild ready)"
}
return fmt.Sprintf("%s%s", displayName, prebuildText)
}

View file

@ -0,0 +1,126 @@
package codespace
import (
"context"
"fmt"
"testing"
"time"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/iostreams"
)
func TestApp_Create(t *testing.T) {
type fields struct {
apiClient apiClient
}
tests := []struct {
name string
fields fields
opts createOptions
wantErr bool
wantStdout string
wantStderr string
}{
{
name: "create codespace with default branch and 30m idle timeout",
fields: fields{
apiClient: &apiClientMock{
GetCodespaceRegionLocationFunc: func(ctx context.Context) (string, error) {
return "EUROPE", nil
},
GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
return &api.Repository{
ID: 1234,
FullName: nwo,
DefaultBranch: "main",
}, nil
},
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) {
return []*api.Machine{
{
Name: "GIGA",
DisplayName: "Gigabits of a machine",
},
}, nil
},
CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
if params.Branch != "main" {
return nil, fmt.Errorf("got branch %q, want %q", params.Branch, "main")
}
if params.IdleTimeoutMinutes != 30 {
return nil, fmt.Errorf("idle timeout minutes was %v", params.IdleTimeoutMinutes)
}
return &api.Codespace{
Name: "monalisa-dotfiles-abcd1234",
}, nil
},
},
},
opts: createOptions{
repo: "monalisa/dotfiles",
branch: "",
machine: "GIGA",
showStatus: false,
idleTimeout: 30 * time.Minute,
},
wantStdout: "monalisa-dotfiles-abcd1234\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
a := &App{
io: io,
apiClient: tt.fields.apiClient,
}
if err := a.Create(context.Background(), tt.opts); (err != nil) != tt.wantErr {
t.Errorf("App.Create() error = %v, wantErr %v", err, tt.wantErr)
}
if got := stdout.String(); got != tt.wantStdout {
t.Errorf("stdout = %v, want %v", got, tt.wantStdout)
}
if got := stderr.String(); got != tt.wantStderr {
t.Errorf("stderr = %v, want %v", got, tt.wantStderr)
}
})
}
}
func TestBuildDisplayName(t *testing.T) {
tests := []struct {
name string
prebuildAvailability string
expectedDisplayName string
}{
{
name: "prebuild availability is pool",
prebuildAvailability: "pool",
expectedDisplayName: "4 cores, 8 GB RAM, 32 GB storage (Prebuild ready)",
},
{
name: "prebuild availability is blob",
prebuildAvailability: "blob",
expectedDisplayName: "4 cores, 8 GB RAM, 32 GB storage (Prebuild ready)",
},
{
name: "prebuild availability is none",
prebuildAvailability: "none",
expectedDisplayName: "4 cores, 8 GB RAM, 32 GB storage",
},
{
name: "prebuild availability is empty",
prebuildAvailability: "",
expectedDisplayName: "4 cores, 8 GB RAM, 32 GB storage",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
displayName := buildDisplayName("4 cores, 8 GB RAM, 32 GB storage", tt.prebuildAvailability)
if displayName != tt.expectedDisplayName {
t.Errorf("displayName = %q, expectedDisplayName %q", displayName, tt.expectedDisplayName)
}
})
}
}

179
pkg/cmd/codespace/delete.go Normal file
View file

@ -0,0 +1,179 @@
package codespace
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
)
type deleteOptions struct {
deleteAll bool
skipConfirm bool
codespaceName string
repoFilter string
keepDays uint16
isInteractive bool
now func() time.Time
prompter prompter
}
//go:generate moq -fmt goimports -rm -skip-ensure -out mock_prompter.go . prompter
type prompter interface {
Confirm(message string) (bool, error)
}
func newDeleteCmd(app *App) *cobra.Command {
opts := deleteOptions{
isInteractive: hasTTY,
now: time.Now,
prompter: &surveyPrompter{},
}
deleteCmd := &cobra.Command{
Use: "delete",
Short: "Delete a codespace",
Args: noArgsConstraint,
RunE: func(cmd *cobra.Command, args []string) error {
if opts.deleteAll && opts.repoFilter != "" {
return errors.New("both --all and --repo is not supported")
}
return app.Delete(cmd.Context(), opts)
},
}
deleteCmd.Flags().StringVarP(&opts.codespaceName, "codespace", "c", "", "Name of the codespace")
deleteCmd.Flags().BoolVar(&opts.deleteAll, "all", false, "Delete all codespaces")
deleteCmd.Flags().StringVarP(&opts.repoFilter, "repo", "r", "", "Delete codespaces for a `repository`")
deleteCmd.Flags().BoolVarP(&opts.skipConfirm, "force", "f", false, "Skip confirmation for codespaces that contain unsaved changes")
deleteCmd.Flags().Uint16Var(&opts.keepDays, "days", 0, "Delete codespaces older than `N` days")
return deleteCmd
}
func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
var codespaces []*api.Codespace
nameFilter := opts.codespaceName
if nameFilter == "" {
a.StartProgressIndicatorWithLabel("Fetching codespaces")
codespaces, err = a.apiClient.ListCodespaces(ctx, -1)
a.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error getting codespaces: %w", err)
}
if !opts.deleteAll && opts.repoFilter == "" {
c, err := chooseCodespaceFromList(ctx, codespaces)
if err != nil {
return fmt.Errorf("error choosing codespace: %w", err)
}
nameFilter = c.Name
}
} else {
a.StartProgressIndicatorWithLabel("Fetching codespace")
codespace, err := a.apiClient.GetCodespace(ctx, nameFilter, false)
a.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error fetching codespace information: %w", err)
}
codespaces = []*api.Codespace{codespace}
}
codespacesToDelete := make([]*api.Codespace, 0, len(codespaces))
lastUpdatedCutoffTime := opts.now().AddDate(0, 0, -int(opts.keepDays))
for _, c := range codespaces {
if nameFilter != "" && c.Name != nameFilter {
continue
}
if opts.repoFilter != "" && !strings.EqualFold(c.Repository.FullName, opts.repoFilter) {
continue
}
if opts.keepDays > 0 {
t, err := time.Parse(time.RFC3339, c.LastUsedAt)
if err != nil {
return fmt.Errorf("error parsing last_used_at timestamp %q: %w", c.LastUsedAt, err)
}
if t.After(lastUpdatedCutoffTime) {
continue
}
}
if !opts.skipConfirm {
confirmed, err := confirmDeletion(opts.prompter, c, opts.isInteractive)
if err != nil {
return fmt.Errorf("unable to confirm: %w", err)
}
if !confirmed {
continue
}
}
codespacesToDelete = append(codespacesToDelete, c)
}
if len(codespacesToDelete) == 0 {
return errors.New("no codespaces to delete")
}
progressLabel := "Deleting codespace"
if len(codespacesToDelete) > 1 {
progressLabel = "Deleting codespaces"
}
a.StartProgressIndicatorWithLabel(progressLabel)
defer a.StopProgressIndicator()
var g errgroup.Group
for _, c := range codespacesToDelete {
codespaceName := c.Name
g.Go(func() error {
if err := a.apiClient.DeleteCodespace(ctx, codespaceName); err != nil {
a.errLogger.Printf("error deleting codespace %q: %v\n", codespaceName, err)
return err
}
return nil
})
}
if err := g.Wait(); err != nil {
return errors.New("some codespaces failed to delete")
}
return nil
}
func confirmDeletion(p prompter, apiCodespace *api.Codespace, isInteractive bool) (bool, error) {
cs := codespace{apiCodespace}
if !cs.hasUnsavedChanges() {
return true, nil
}
if !isInteractive {
return false, fmt.Errorf("codespace %s has unsaved changes (use --force to override)", cs.Name)
}
return p.Confirm(fmt.Sprintf("Codespace %s has unsaved changes. OK to delete?", cs.Name))
}
type surveyPrompter struct{}
func (p *surveyPrompter) Confirm(message string) (bool, error) {
var confirmed struct {
Confirmed bool
}
q := []*survey.Question{
{
Name: "confirmed",
Prompt: &survey.Confirm{
Message: message,
},
},
}
if err := ask(q, &confirmed); err != nil {
return false, fmt.Errorf("failed to prompt: %w", err)
}
return confirmed.Confirmed, nil
}

View file

@ -0,0 +1,237 @@
package codespace
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"testing"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/iostreams"
)
func TestDelete(t *testing.T) {
user := &api.User{Login: "hubot"}
now, _ := time.Parse(time.RFC3339, "2021-09-22T00:00:00Z")
daysAgo := func(n int) string {
return now.Add(time.Hour * -time.Duration(24*n)).Format(time.RFC3339)
}
tests := []struct {
name string
opts deleteOptions
codespaces []*api.Codespace
confirms map[string]bool
deleteErr error
wantErr bool
wantDeleted []string
wantStdout string
wantStderr string
}{
{
name: "by name",
opts: deleteOptions{
codespaceName: "hubot-robawt-abc",
},
codespaces: []*api.Codespace{
{
Name: "hubot-robawt-abc",
},
},
wantDeleted: []string{"hubot-robawt-abc"},
wantStdout: "",
},
{
name: "by repo",
opts: deleteOptions{
repoFilter: "monalisa/spoon-knife",
},
codespaces: []*api.Codespace{
{
Name: "monalisa-spoonknife-123",
Repository: api.Repository{
FullName: "monalisa/Spoon-Knife",
},
},
{
Name: "hubot-robawt-abc",
Repository: api.Repository{
FullName: "hubot/ROBAWT",
},
},
{
Name: "monalisa-spoonknife-c4f3",
Repository: api.Repository{
FullName: "monalisa/Spoon-Knife",
},
},
},
wantDeleted: []string{"monalisa-spoonknife-123", "monalisa-spoonknife-c4f3"},
wantStdout: "",
},
{
name: "unused",
opts: deleteOptions{
deleteAll: true,
keepDays: 3,
},
codespaces: []*api.Codespace{
{
Name: "monalisa-spoonknife-123",
LastUsedAt: daysAgo(1),
},
{
Name: "hubot-robawt-abc",
LastUsedAt: daysAgo(4),
},
{
Name: "monalisa-spoonknife-c4f3",
LastUsedAt: daysAgo(10),
},
},
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"},
wantStdout: "",
},
{
name: "deletion failed",
opts: deleteOptions{
deleteAll: true,
},
codespaces: []*api.Codespace{
{
Name: "monalisa-spoonknife-123",
},
{
Name: "hubot-robawt-abc",
},
},
deleteErr: errors.New("aborted by test"),
wantErr: true,
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-123"},
wantStderr: heredoc.Doc(`
error deleting codespace "hubot-robawt-abc": aborted by test
error deleting codespace "monalisa-spoonknife-123": aborted by test
`),
},
{
name: "with confirm",
opts: deleteOptions{
isInteractive: true,
deleteAll: true,
skipConfirm: false,
},
codespaces: []*api.Codespace{
{
Name: "monalisa-spoonknife-123",
GitStatus: api.CodespaceGitStatus{
HasUnpushedChanges: true,
},
},
{
Name: "hubot-robawt-abc",
GitStatus: api.CodespaceGitStatus{
HasUncommitedChanges: true,
},
},
{
Name: "monalisa-spoonknife-c4f3",
GitStatus: api.CodespaceGitStatus{
HasUnpushedChanges: false,
HasUncommitedChanges: false,
},
},
},
confirms: map[string]bool{
"Codespace monalisa-spoonknife-123 has unsaved changes. OK to delete?": false,
"Codespace hubot-robawt-abc has unsaved changes. OK to delete?": true,
},
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"},
wantStdout: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
apiMock := &apiClientMock{
GetUserFunc: func(_ context.Context) (*api.User, error) {
return user, nil
},
DeleteCodespaceFunc: func(_ context.Context, name string) error {
if tt.deleteErr != nil {
return tt.deleteErr
}
return nil
},
}
if tt.opts.codespaceName == "" {
apiMock.ListCodespacesFunc = func(_ context.Context, num int) ([]*api.Codespace, error) {
return tt.codespaces, nil
}
} else {
apiMock.GetCodespaceFunc = func(_ context.Context, name string, includeConnection bool) (*api.Codespace, error) {
return tt.codespaces[0], nil
}
}
opts := tt.opts
opts.now = func() time.Time { return now }
opts.prompter = &prompterMock{
ConfirmFunc: func(msg string) (bool, error) {
res, found := tt.confirms[msg]
if !found {
return false, fmt.Errorf("unexpected prompt %q", msg)
}
return res, nil
},
}
io, _, stdout, stderr := iostreams.Test()
io.SetStdinTTY(true)
io.SetStdoutTTY(true)
app := NewApp(io, nil, apiMock)
err := app.Delete(context.Background(), opts)
if (err != nil) != tt.wantErr {
t.Errorf("delete() error = %v, wantErr %v", err, tt.wantErr)
}
var gotDeleted []string
for _, delArgs := range apiMock.DeleteCodespaceCalls() {
gotDeleted = append(gotDeleted, delArgs.Name)
}
sort.Strings(gotDeleted)
if !sliceEquals(gotDeleted, tt.wantDeleted) {
t.Errorf("deleted %q, want %q", gotDeleted, tt.wantDeleted)
}
if out := stdout.String(); out != tt.wantStdout {
t.Errorf("stdout = %q, want %q", out, tt.wantStdout)
}
if out := sortLines(stderr.String()); out != tt.wantStderr {
t.Errorf("stderr = %q, want %q", out, tt.wantStderr)
}
})
}
}
func sliceEquals(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func sortLines(s string) string {
trailing := ""
if strings.HasSuffix(s, "\n") {
s = strings.TrimSuffix(s, "\n")
trailing = "\n"
}
lines := strings.Split(s, "\n")
sort.Strings(lines)
return strings.Join(lines, "\n") + trailing
}

94
pkg/cmd/codespace/list.go Normal file
View file

@ -0,0 +1,94 @@
package codespace
import (
"context"
"fmt"
"time"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/utils"
"github.com/spf13/cobra"
)
func newListCmd(app *App) *cobra.Command {
var limit int
var exporter cmdutil.Exporter
listCmd := &cobra.Command{
Use: "list",
Short: "List your codespaces",
Args: noArgsConstraint,
RunE: func(cmd *cobra.Command, args []string) error {
if limit < 1 {
return cmdutil.FlagErrorf("invalid limit: %v", limit)
}
return app.List(cmd.Context(), limit, exporter)
},
}
listCmd.Flags().IntVarP(&limit, "limit", "L", 30, "Maximum number of codespaces to list")
cmdutil.AddJSONFlags(listCmd, &exporter, api.CodespaceFields)
return listCmd
}
func (a *App) List(ctx context.Context, limit int, exporter cmdutil.Exporter) error {
a.StartProgressIndicatorWithLabel("Fetching codespaces")
codespaces, err := a.apiClient.ListCodespaces(ctx, limit)
a.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error getting codespaces: %w", err)
}
if err := a.io.StartPager(); err != nil {
a.errLogger.Printf("error starting pager: %v", err)
}
defer a.io.StopPager()
if exporter != nil {
return exporter.Write(a.io, codespaces)
}
tp := utils.NewTablePrinter(a.io)
if tp.IsTTY() {
tp.AddField("NAME", nil, nil)
tp.AddField("REPOSITORY", nil, nil)
tp.AddField("BRANCH", nil, nil)
tp.AddField("STATE", nil, nil)
tp.AddField("CREATED AT", nil, nil)
tp.EndRow()
}
cs := a.io.ColorScheme()
for _, apiCodespace := range codespaces {
c := codespace{apiCodespace}
var stateColor func(string) string
switch c.State {
case api.CodespaceStateStarting:
stateColor = cs.Yellow
case api.CodespaceStateAvailable:
stateColor = cs.Green
}
tp.AddField(c.Name, nil, cs.Yellow)
tp.AddField(c.Repository.FullName, nil, nil)
tp.AddField(c.branchWithGitStatus(), nil, cs.Cyan)
tp.AddField(c.State, nil, stateColor)
if tp.IsTTY() {
ct, err := time.Parse(time.RFC3339, c.CreatedAt)
if err != nil {
return fmt.Errorf("error parsing date %q: %w", c.CreatedAt, err)
}
tp.AddField(utils.FuzzyAgoAbbr(time.Now(), ct), nil, cs.Gray)
} else {
tp.AddField(c.CreatedAt, nil, nil)
}
tp.EndRow()
}
return tp.Render()
}

108
pkg/cmd/codespace/logs.go Normal file
View file

@ -0,0 +1,108 @@
package codespace
import (
"context"
"fmt"
"net"
"github.com/cli/cli/v2/internal/codespaces"
"github.com/cli/cli/v2/pkg/liveshare"
"github.com/spf13/cobra"
)
func newLogsCmd(app *App) *cobra.Command {
var (
codespace string
follow bool
)
logsCmd := &cobra.Command{
Use: "logs",
Short: "Access codespace logs",
Args: noArgsConstraint,
RunE: func(cmd *cobra.Command, args []string) error {
return app.Logs(cmd.Context(), codespace, follow)
},
}
logsCmd.Flags().StringVarP(&codespace, "codespace", "c", "", "Name of the codespace")
logsCmd.Flags().BoolVarP(&follow, "follow", "f", false, "Tail and follow the logs")
return logsCmd
}
func (a *App) Logs(ctx context.Context, codespaceName string, follow bool) (err error) {
// Ensure all child tasks (port forwarding, remote exec) terminate before return.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName)
if err != nil {
return fmt.Errorf("get or choose codespace: %w", err)
}
authkeys := make(chan error, 1)
go func() {
authkeys <- checkAuthorizedKeys(ctx, a.apiClient)
}()
session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace)
if err != nil {
return fmt.Errorf("connecting to codespace: %w", err)
}
defer safeClose(session, &err)
if err := <-authkeys; err != nil {
return err
}
// Ensure local port is listening before client (getPostCreateOutput) connects.
listen, err := net.Listen("tcp", "127.0.0.1:0") // arbitrary port
if err != nil {
return err
}
defer listen.Close()
localPort := listen.Addr().(*net.TCPAddr).Port
a.StartProgressIndicatorWithLabel("Fetching SSH Details")
remoteSSHServerPort, sshUser, err := session.StartSSHServer(ctx)
a.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error getting ssh server details: %w", err)
}
cmdType := "cat"
if follow {
cmdType = "tail -f"
}
dst := fmt.Sprintf("%s@localhost", sshUser)
cmd, err := codespaces.NewRemoteCommand(
ctx, localPort, dst, fmt.Sprintf("%s /workspaces/.codespaces/.persistedshare/creation.log", cmdType),
)
if err != nil {
return fmt.Errorf("remote command: %w", err)
}
tunnelClosed := make(chan error, 1)
go func() {
fwd := liveshare.NewPortForwarder(session, "sshd", remoteSSHServerPort, false)
tunnelClosed <- fwd.ForwardToListener(ctx, listen) // error is non-nil
}()
cmdDone := make(chan error, 1)
go func() {
cmdDone <- cmd.Run()
}()
select {
case err := <-tunnelClosed:
return fmt.Errorf("connection closed: %w", err)
case err := <-cmdDone:
if err != nil {
return fmt.Errorf("error retrieving logs: %w", err)
}
return nil // success
}
}

View file

@ -0,0 +1,629 @@
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package codespace
import (
"context"
"sync"
"github.com/cli/cli/v2/internal/codespaces/api"
)
// apiClientMock is a mock implementation of apiClient.
//
// func TestSomethingThatUsesapiClient(t *testing.T) {
//
// // make and configure a mocked apiClient
// mockedapiClient := &apiClientMock{
// AuthorizedKeysFunc: func(ctx context.Context, user string) ([]byte, error) {
// panic("mock out the AuthorizedKeys method")
// },
// CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
// panic("mock out the CreateCodespace method")
// },
// DeleteCodespaceFunc: func(ctx context.Context, name string) error {
// panic("mock out the DeleteCodespace method")
// },
// GetCodespaceFunc: func(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) {
// panic("mock out the GetCodespace method")
// },
// GetCodespaceRegionLocationFunc: func(ctx context.Context) (string, error) {
// panic("mock out the GetCodespaceRegionLocation method")
// },
// GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) {
// panic("mock out the GetCodespaceRepositoryContents method")
// },
// GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error) {
// panic("mock out the GetCodespacesMachines method")
// },
// GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
// panic("mock out the GetRepository method")
// },
// GetUserFunc: func(ctx context.Context) (*api.User, error) {
// panic("mock out the GetUser method")
// },
// ListCodespacesFunc: func(ctx context.Context, limit int) ([]*api.Codespace, error) {
// panic("mock out the ListCodespaces method")
// },
// StartCodespaceFunc: func(ctx context.Context, name string) error {
// panic("mock out the StartCodespace method")
// },
// StopCodespaceFunc: func(ctx context.Context, name string) error {
// panic("mock out the StopCodespace method")
// },
// }
//
// // use mockedapiClient in code that requires apiClient
// // and then make assertions.
//
// }
type apiClientMock struct {
// AuthorizedKeysFunc mocks the AuthorizedKeys method.
AuthorizedKeysFunc func(ctx context.Context, user string) ([]byte, error)
// CreateCodespaceFunc mocks the CreateCodespace method.
CreateCodespaceFunc func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error)
// DeleteCodespaceFunc mocks the DeleteCodespace method.
DeleteCodespaceFunc func(ctx context.Context, name string) error
// GetCodespaceFunc mocks the GetCodespace method.
GetCodespaceFunc func(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error)
// GetCodespaceRegionLocationFunc mocks the GetCodespaceRegionLocation method.
GetCodespaceRegionLocationFunc func(ctx context.Context) (string, error)
// GetCodespaceRepositoryContentsFunc mocks the GetCodespaceRepositoryContents method.
GetCodespaceRepositoryContentsFunc func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
// GetCodespacesMachinesFunc mocks the GetCodespacesMachines method.
GetCodespacesMachinesFunc func(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error)
// GetRepositoryFunc mocks the GetRepository method.
GetRepositoryFunc func(ctx context.Context, nwo string) (*api.Repository, error)
// GetUserFunc mocks the GetUser method.
GetUserFunc func(ctx context.Context) (*api.User, error)
// ListCodespacesFunc mocks the ListCodespaces method.
ListCodespacesFunc func(ctx context.Context, limit int) ([]*api.Codespace, error)
// StartCodespaceFunc mocks the StartCodespace method.
StartCodespaceFunc func(ctx context.Context, name string) error
// StopCodespaceFunc mocks the StopCodespace method.
StopCodespaceFunc func(ctx context.Context, name string) error
// calls tracks calls to the methods.
calls struct {
// AuthorizedKeys holds details about calls to the AuthorizedKeys method.
AuthorizedKeys []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// User is the user argument value.
User string
}
// CreateCodespace holds details about calls to the CreateCodespace method.
CreateCodespace []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Params is the params argument value.
Params *api.CreateCodespaceParams
}
// DeleteCodespace holds details about calls to the DeleteCodespace method.
DeleteCodespace []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Name is the name argument value.
Name string
}
// GetCodespace holds details about calls to the GetCodespace method.
GetCodespace []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Name is the name argument value.
Name string
// IncludeConnection is the includeConnection argument value.
IncludeConnection bool
}
// GetCodespaceRegionLocation holds details about calls to the GetCodespaceRegionLocation method.
GetCodespaceRegionLocation []struct {
// Ctx is the ctx argument value.
Ctx context.Context
}
// GetCodespaceRepositoryContents holds details about calls to the GetCodespaceRepositoryContents method.
GetCodespaceRepositoryContents []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Codespace is the codespace argument value.
Codespace *api.Codespace
// Path is the path argument value.
Path string
}
// GetCodespacesMachines holds details about calls to the GetCodespacesMachines method.
GetCodespacesMachines []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// RepoID is the repoID argument value.
RepoID int
// Branch is the branch argument value.
Branch string
// Location is the location argument value.
Location string
}
// GetRepository holds details about calls to the GetRepository method.
GetRepository []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Nwo is the nwo argument value.
Nwo string
}
// GetUser holds details about calls to the GetUser method.
GetUser []struct {
// Ctx is the ctx argument value.
Ctx context.Context
}
// ListCodespaces holds details about calls to the ListCodespaces method.
ListCodespaces []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Limit is the limit argument value.
Limit int
}
// StartCodespace holds details about calls to the StartCodespace method.
StartCodespace []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Name is the name argument value.
Name string
}
// StopCodespace holds details about calls to the StopCodespace method.
StopCodespace []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Name is the name argument value.
Name string
}
}
lockAuthorizedKeys sync.RWMutex
lockCreateCodespace sync.RWMutex
lockDeleteCodespace sync.RWMutex
lockGetCodespace sync.RWMutex
lockGetCodespaceRegionLocation sync.RWMutex
lockGetCodespaceRepositoryContents sync.RWMutex
lockGetCodespacesMachines sync.RWMutex
lockGetRepository sync.RWMutex
lockGetUser sync.RWMutex
lockListCodespaces sync.RWMutex
lockStartCodespace sync.RWMutex
lockStopCodespace sync.RWMutex
}
// AuthorizedKeys calls AuthorizedKeysFunc.
func (mock *apiClientMock) AuthorizedKeys(ctx context.Context, user string) ([]byte, error) {
if mock.AuthorizedKeysFunc == nil {
panic("apiClientMock.AuthorizedKeysFunc: method is nil but apiClient.AuthorizedKeys was just called")
}
callInfo := struct {
Ctx context.Context
User string
}{
Ctx: ctx,
User: user,
}
mock.lockAuthorizedKeys.Lock()
mock.calls.AuthorizedKeys = append(mock.calls.AuthorizedKeys, callInfo)
mock.lockAuthorizedKeys.Unlock()
return mock.AuthorizedKeysFunc(ctx, user)
}
// AuthorizedKeysCalls gets all the calls that were made to AuthorizedKeys.
// Check the length with:
// len(mockedapiClient.AuthorizedKeysCalls())
func (mock *apiClientMock) AuthorizedKeysCalls() []struct {
Ctx context.Context
User string
} {
var calls []struct {
Ctx context.Context
User string
}
mock.lockAuthorizedKeys.RLock()
calls = mock.calls.AuthorizedKeys
mock.lockAuthorizedKeys.RUnlock()
return calls
}
// CreateCodespace calls CreateCodespaceFunc.
func (mock *apiClientMock) CreateCodespace(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
if mock.CreateCodespaceFunc == nil {
panic("apiClientMock.CreateCodespaceFunc: method is nil but apiClient.CreateCodespace was just called")
}
callInfo := struct {
Ctx context.Context
Params *api.CreateCodespaceParams
}{
Ctx: ctx,
Params: params,
}
mock.lockCreateCodespace.Lock()
mock.calls.CreateCodespace = append(mock.calls.CreateCodespace, callInfo)
mock.lockCreateCodespace.Unlock()
return mock.CreateCodespaceFunc(ctx, params)
}
// CreateCodespaceCalls gets all the calls that were made to CreateCodespace.
// Check the length with:
// len(mockedapiClient.CreateCodespaceCalls())
func (mock *apiClientMock) CreateCodespaceCalls() []struct {
Ctx context.Context
Params *api.CreateCodespaceParams
} {
var calls []struct {
Ctx context.Context
Params *api.CreateCodespaceParams
}
mock.lockCreateCodespace.RLock()
calls = mock.calls.CreateCodespace
mock.lockCreateCodespace.RUnlock()
return calls
}
// DeleteCodespace calls DeleteCodespaceFunc.
func (mock *apiClientMock) DeleteCodespace(ctx context.Context, name string) error {
if mock.DeleteCodespaceFunc == nil {
panic("apiClientMock.DeleteCodespaceFunc: method is nil but apiClient.DeleteCodespace was just called")
}
callInfo := struct {
Ctx context.Context
Name string
}{
Ctx: ctx,
Name: name,
}
mock.lockDeleteCodespace.Lock()
mock.calls.DeleteCodespace = append(mock.calls.DeleteCodespace, callInfo)
mock.lockDeleteCodespace.Unlock()
return mock.DeleteCodespaceFunc(ctx, name)
}
// DeleteCodespaceCalls gets all the calls that were made to DeleteCodespace.
// Check the length with:
// len(mockedapiClient.DeleteCodespaceCalls())
func (mock *apiClientMock) DeleteCodespaceCalls() []struct {
Ctx context.Context
Name string
} {
var calls []struct {
Ctx context.Context
Name string
}
mock.lockDeleteCodespace.RLock()
calls = mock.calls.DeleteCodespace
mock.lockDeleteCodespace.RUnlock()
return calls
}
// GetCodespace calls GetCodespaceFunc.
func (mock *apiClientMock) GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) {
if mock.GetCodespaceFunc == nil {
panic("apiClientMock.GetCodespaceFunc: method is nil but apiClient.GetCodespace was just called")
}
callInfo := struct {
Ctx context.Context
Name string
IncludeConnection bool
}{
Ctx: ctx,
Name: name,
IncludeConnection: includeConnection,
}
mock.lockGetCodespace.Lock()
mock.calls.GetCodespace = append(mock.calls.GetCodespace, callInfo)
mock.lockGetCodespace.Unlock()
return mock.GetCodespaceFunc(ctx, name, includeConnection)
}
// GetCodespaceCalls gets all the calls that were made to GetCodespace.
// Check the length with:
// len(mockedapiClient.GetCodespaceCalls())
func (mock *apiClientMock) GetCodespaceCalls() []struct {
Ctx context.Context
Name string
IncludeConnection bool
} {
var calls []struct {
Ctx context.Context
Name string
IncludeConnection bool
}
mock.lockGetCodespace.RLock()
calls = mock.calls.GetCodespace
mock.lockGetCodespace.RUnlock()
return calls
}
// GetCodespaceRegionLocation calls GetCodespaceRegionLocationFunc.
func (mock *apiClientMock) GetCodespaceRegionLocation(ctx context.Context) (string, error) {
if mock.GetCodespaceRegionLocationFunc == nil {
panic("apiClientMock.GetCodespaceRegionLocationFunc: method is nil but apiClient.GetCodespaceRegionLocation was just called")
}
callInfo := struct {
Ctx context.Context
}{
Ctx: ctx,
}
mock.lockGetCodespaceRegionLocation.Lock()
mock.calls.GetCodespaceRegionLocation = append(mock.calls.GetCodespaceRegionLocation, callInfo)
mock.lockGetCodespaceRegionLocation.Unlock()
return mock.GetCodespaceRegionLocationFunc(ctx)
}
// GetCodespaceRegionLocationCalls gets all the calls that were made to GetCodespaceRegionLocation.
// Check the length with:
// len(mockedapiClient.GetCodespaceRegionLocationCalls())
func (mock *apiClientMock) GetCodespaceRegionLocationCalls() []struct {
Ctx context.Context
} {
var calls []struct {
Ctx context.Context
}
mock.lockGetCodespaceRegionLocation.RLock()
calls = mock.calls.GetCodespaceRegionLocation
mock.lockGetCodespaceRegionLocation.RUnlock()
return calls
}
// GetCodespaceRepositoryContents calls GetCodespaceRepositoryContentsFunc.
func (mock *apiClientMock) GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) {
if mock.GetCodespaceRepositoryContentsFunc == nil {
panic("apiClientMock.GetCodespaceRepositoryContentsFunc: method is nil but apiClient.GetCodespaceRepositoryContents was just called")
}
callInfo := struct {
Ctx context.Context
Codespace *api.Codespace
Path string
}{
Ctx: ctx,
Codespace: codespace,
Path: path,
}
mock.lockGetCodespaceRepositoryContents.Lock()
mock.calls.GetCodespaceRepositoryContents = append(mock.calls.GetCodespaceRepositoryContents, callInfo)
mock.lockGetCodespaceRepositoryContents.Unlock()
return mock.GetCodespaceRepositoryContentsFunc(ctx, codespace, path)
}
// GetCodespaceRepositoryContentsCalls gets all the calls that were made to GetCodespaceRepositoryContents.
// Check the length with:
// len(mockedapiClient.GetCodespaceRepositoryContentsCalls())
func (mock *apiClientMock) GetCodespaceRepositoryContentsCalls() []struct {
Ctx context.Context
Codespace *api.Codespace
Path string
} {
var calls []struct {
Ctx context.Context
Codespace *api.Codespace
Path string
}
mock.lockGetCodespaceRepositoryContents.RLock()
calls = mock.calls.GetCodespaceRepositoryContents
mock.lockGetCodespaceRepositoryContents.RUnlock()
return calls
}
// GetCodespacesMachines calls GetCodespacesMachinesFunc.
func (mock *apiClientMock) GetCodespacesMachines(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error) {
if mock.GetCodespacesMachinesFunc == nil {
panic("apiClientMock.GetCodespacesMachinesFunc: method is nil but apiClient.GetCodespacesMachines was just called")
}
callInfo := struct {
Ctx context.Context
RepoID int
Branch string
Location string
}{
Ctx: ctx,
RepoID: repoID,
Branch: branch,
Location: location,
}
mock.lockGetCodespacesMachines.Lock()
mock.calls.GetCodespacesMachines = append(mock.calls.GetCodespacesMachines, callInfo)
mock.lockGetCodespacesMachines.Unlock()
return mock.GetCodespacesMachinesFunc(ctx, repoID, branch, location)
}
// GetCodespacesMachinesCalls gets all the calls that were made to GetCodespacesMachines.
// Check the length with:
// len(mockedapiClient.GetCodespacesMachinesCalls())
func (mock *apiClientMock) GetCodespacesMachinesCalls() []struct {
Ctx context.Context
RepoID int
Branch string
Location string
} {
var calls []struct {
Ctx context.Context
RepoID int
Branch string
Location string
}
mock.lockGetCodespacesMachines.RLock()
calls = mock.calls.GetCodespacesMachines
mock.lockGetCodespacesMachines.RUnlock()
return calls
}
// GetRepository calls GetRepositoryFunc.
func (mock *apiClientMock) GetRepository(ctx context.Context, nwo string) (*api.Repository, error) {
if mock.GetRepositoryFunc == nil {
panic("apiClientMock.GetRepositoryFunc: method is nil but apiClient.GetRepository was just called")
}
callInfo := struct {
Ctx context.Context
Nwo string
}{
Ctx: ctx,
Nwo: nwo,
}
mock.lockGetRepository.Lock()
mock.calls.GetRepository = append(mock.calls.GetRepository, callInfo)
mock.lockGetRepository.Unlock()
return mock.GetRepositoryFunc(ctx, nwo)
}
// GetRepositoryCalls gets all the calls that were made to GetRepository.
// Check the length with:
// len(mockedapiClient.GetRepositoryCalls())
func (mock *apiClientMock) GetRepositoryCalls() []struct {
Ctx context.Context
Nwo string
} {
var calls []struct {
Ctx context.Context
Nwo string
}
mock.lockGetRepository.RLock()
calls = mock.calls.GetRepository
mock.lockGetRepository.RUnlock()
return calls
}
// GetUser calls GetUserFunc.
func (mock *apiClientMock) GetUser(ctx context.Context) (*api.User, error) {
if mock.GetUserFunc == nil {
panic("apiClientMock.GetUserFunc: method is nil but apiClient.GetUser was just called")
}
callInfo := struct {
Ctx context.Context
}{
Ctx: ctx,
}
mock.lockGetUser.Lock()
mock.calls.GetUser = append(mock.calls.GetUser, callInfo)
mock.lockGetUser.Unlock()
return mock.GetUserFunc(ctx)
}
// GetUserCalls gets all the calls that were made to GetUser.
// Check the length with:
// len(mockedapiClient.GetUserCalls())
func (mock *apiClientMock) GetUserCalls() []struct {
Ctx context.Context
} {
var calls []struct {
Ctx context.Context
}
mock.lockGetUser.RLock()
calls = mock.calls.GetUser
mock.lockGetUser.RUnlock()
return calls
}
// ListCodespaces calls ListCodespacesFunc.
func (mock *apiClientMock) ListCodespaces(ctx context.Context, limit int) ([]*api.Codespace, error) {
if mock.ListCodespacesFunc == nil {
panic("apiClientMock.ListCodespacesFunc: method is nil but apiClient.ListCodespaces was just called")
}
callInfo := struct {
Ctx context.Context
Limit int
}{
Ctx: ctx,
Limit: limit,
}
mock.lockListCodespaces.Lock()
mock.calls.ListCodespaces = append(mock.calls.ListCodespaces, callInfo)
mock.lockListCodespaces.Unlock()
return mock.ListCodespacesFunc(ctx, limit)
}
// ListCodespacesCalls gets all the calls that were made to ListCodespaces.
// Check the length with:
// len(mockedapiClient.ListCodespacesCalls())
func (mock *apiClientMock) ListCodespacesCalls() []struct {
Ctx context.Context
Limit int
} {
var calls []struct {
Ctx context.Context
Limit int
}
mock.lockListCodespaces.RLock()
calls = mock.calls.ListCodespaces
mock.lockListCodespaces.RUnlock()
return calls
}
// StartCodespace calls StartCodespaceFunc.
func (mock *apiClientMock) StartCodespace(ctx context.Context, name string) error {
if mock.StartCodespaceFunc == nil {
panic("apiClientMock.StartCodespaceFunc: method is nil but apiClient.StartCodespace was just called")
}
callInfo := struct {
Ctx context.Context
Name string
}{
Ctx: ctx,
Name: name,
}
mock.lockStartCodespace.Lock()
mock.calls.StartCodespace = append(mock.calls.StartCodespace, callInfo)
mock.lockStartCodespace.Unlock()
return mock.StartCodespaceFunc(ctx, name)
}
// StartCodespaceCalls gets all the calls that were made to StartCodespace.
// Check the length with:
// len(mockedapiClient.StartCodespaceCalls())
func (mock *apiClientMock) StartCodespaceCalls() []struct {
Ctx context.Context
Name string
} {
var calls []struct {
Ctx context.Context
Name string
}
mock.lockStartCodespace.RLock()
calls = mock.calls.StartCodespace
mock.lockStartCodespace.RUnlock()
return calls
}
// StopCodespace calls StopCodespaceFunc.
func (mock *apiClientMock) StopCodespace(ctx context.Context, name string) error {
if mock.StopCodespaceFunc == nil {
panic("apiClientMock.StopCodespaceFunc: method is nil but apiClient.StopCodespace was just called")
}
callInfo := struct {
Ctx context.Context
Name string
}{
Ctx: ctx,
Name: name,
}
mock.lockStopCodespace.Lock()
mock.calls.StopCodespace = append(mock.calls.StopCodespace, callInfo)
mock.lockStopCodespace.Unlock()
return mock.StopCodespaceFunc(ctx, name)
}
// StopCodespaceCalls gets all the calls that were made to StopCodespace.
// Check the length with:
// len(mockedapiClient.StopCodespaceCalls())
func (mock *apiClientMock) StopCodespaceCalls() []struct {
Ctx context.Context
Name string
} {
var calls []struct {
Ctx context.Context
Name string
}
mock.lockStopCodespace.RLock()
calls = mock.calls.StopCodespace
mock.lockStopCodespace.RUnlock()
return calls
}

View file

@ -0,0 +1,69 @@
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package codespace
import (
"sync"
)
// prompterMock is a mock implementation of prompter.
//
// func TestSomethingThatUsesprompter(t *testing.T) {
//
// // make and configure a mocked prompter
// mockedprompter := &prompterMock{
// ConfirmFunc: func(message string) (bool, error) {
// panic("mock out the Confirm method")
// },
// }
//
// // use mockedprompter in code that requires prompter
// // and then make assertions.
//
// }
type prompterMock struct {
// ConfirmFunc mocks the Confirm method.
ConfirmFunc func(message string) (bool, error)
// calls tracks calls to the methods.
calls struct {
// Confirm holds details about calls to the Confirm method.
Confirm []struct {
// Message is the message argument value.
Message string
}
}
lockConfirm sync.RWMutex
}
// Confirm calls ConfirmFunc.
func (mock *prompterMock) Confirm(message string) (bool, error) {
if mock.ConfirmFunc == nil {
panic("prompterMock.ConfirmFunc: method is nil but prompter.Confirm was just called")
}
callInfo := struct {
Message string
}{
Message: message,
}
mock.lockConfirm.Lock()
mock.calls.Confirm = append(mock.calls.Confirm, callInfo)
mock.lockConfirm.Unlock()
return mock.ConfirmFunc(message)
}
// ConfirmCalls gets all the calls that were made to Confirm.
// Check the length with:
// len(mockedprompter.ConfirmCalls())
func (mock *prompterMock) ConfirmCalls() []struct {
Message string
} {
var calls []struct {
Message string
}
mock.lockConfirm.RLock()
calls = mock.calls.Confirm
mock.lockConfirm.RUnlock()
return calls
}

382
pkg/cmd/codespace/ports.go Normal file
View file

@ -0,0 +1,382 @@
package codespace
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net"
"strconv"
"strings"
"github.com/cli/cli/v2/internal/codespaces"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/liveshare"
"github.com/cli/cli/v2/utils"
"github.com/muhammadmuzzammil1998/jsonc"
"github.com/spf13/cobra"
"golang.org/x/sync/errgroup"
)
// newPortsCmd returns a Cobra "ports" command that displays a table of available ports,
// according to the specified flags.
func newPortsCmd(app *App) *cobra.Command {
var codespace string
var exporter cmdutil.Exporter
portsCmd := &cobra.Command{
Use: "ports",
Short: "List ports in a codespace",
Args: noArgsConstraint,
RunE: func(cmd *cobra.Command, args []string) error {
return app.ListPorts(cmd.Context(), codespace, exporter)
},
}
portsCmd.PersistentFlags().StringVarP(&codespace, "codespace", "c", "", "Name of the codespace")
cmdutil.AddJSONFlags(portsCmd, &exporter, portFields)
portsCmd.AddCommand(newPortsForwardCmd(app))
portsCmd.AddCommand(newPortsVisibilityCmd(app))
return portsCmd
}
// ListPorts lists known ports in a codespace.
func (a *App) ListPorts(ctx context.Context, codespaceName string, exporter cmdutil.Exporter) (err error) {
codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName)
if err != nil {
// TODO(josebalius): remove special handling of this error here and it other places
if err == errNoCodespaces {
return err
}
return fmt.Errorf("error choosing codespace: %w", err)
}
devContainerCh := getDevContainer(ctx, a.apiClient, codespace)
session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace)
if err != nil {
return fmt.Errorf("error connecting to codespace: %w", err)
}
defer safeClose(session, &err)
a.StartProgressIndicatorWithLabel("Fetching ports")
ports, err := session.GetSharedServers(ctx)
a.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error getting ports of shared servers: %w", err)
}
devContainerResult := <-devContainerCh
if devContainerResult.err != nil {
// Warn about failure to read the devcontainer file. Not a codespace command error.
a.errLogger.Printf("Failed to get port names: %v", devContainerResult.err.Error())
}
portInfos := make([]*portInfo, len(ports))
for i, p := range ports {
portInfos[i] = &portInfo{
Port: p,
codespace: codespace,
devContainer: devContainerResult.devContainer,
}
}
if err := a.io.StartPager(); err != nil {
a.errLogger.Printf("error starting pager: %v", err)
}
defer a.io.StopPager()
if exporter != nil {
return exporter.Write(a.io, portInfos)
}
cs := a.io.ColorScheme()
tp := utils.NewTablePrinter(a.io)
if tp.IsTTY() {
tp.AddField("LABEL", nil, nil)
tp.AddField("PORT", nil, nil)
tp.AddField("VISIBILITY", nil, nil)
tp.AddField("BROWSE URL", nil, nil)
tp.EndRow()
}
for _, port := range portInfos {
tp.AddField(port.Label(), nil, nil)
tp.AddField(strconv.Itoa(port.SourcePort), nil, cs.Yellow)
tp.AddField(port.Privacy, nil, nil)
tp.AddField(port.BrowseURL(), nil, nil)
tp.EndRow()
}
return tp.Render()
}
type portInfo struct {
*liveshare.Port
codespace *api.Codespace
devContainer *devContainer
}
func (pi *portInfo) BrowseURL() string {
return fmt.Sprintf("https://%s-%d.githubpreview.dev", pi.codespace.Name, pi.Port.SourcePort)
}
func (pi *portInfo) Label() string {
if pi.devContainer != nil {
portStr := strconv.Itoa(pi.Port.SourcePort)
if attributes, ok := pi.devContainer.PortAttributes[portStr]; ok {
return attributes.Label
}
}
return ""
}
var portFields = []string{
"sourcePort",
// "destinationPort", // TODO(mislav): this appears to always be blank?
"visibility",
"label",
"browseUrl",
}
func (pi *portInfo) ExportData(fields []string) map[string]interface{} {
data := map[string]interface{}{}
for _, f := range fields {
switch f {
case "sourcePort":
data[f] = pi.Port.SourcePort
case "destinationPort":
data[f] = pi.Port.DestinationPort
case "visibility":
data[f] = pi.Port.Privacy
case "label":
data[f] = pi.Label()
case "browseUrl":
data[f] = pi.BrowseURL()
default:
panic("unkown field: " + f)
}
}
return data
}
type devContainerResult struct {
devContainer *devContainer
err error
}
type devContainer struct {
PortAttributes map[string]portAttribute `json:"portsAttributes"`
}
type portAttribute struct {
Label string `json:"label"`
}
func getDevContainer(ctx context.Context, apiClient apiClient, codespace *api.Codespace) <-chan devContainerResult {
ch := make(chan devContainerResult, 1)
go func() {
contents, err := apiClient.GetCodespaceRepositoryContents(ctx, codespace, ".devcontainer/devcontainer.json")
if err != nil {
ch <- devContainerResult{nil, fmt.Errorf("error getting content: %w", err)}
return
}
if contents == nil {
ch <- devContainerResult{nil, nil}
return
}
convertedJSON := normalizeJSON(jsonc.ToJSON(contents))
if !jsonc.Valid(convertedJSON) {
ch <- devContainerResult{nil, errors.New("failed to convert json to standard json")}
return
}
var container devContainer
if err := json.Unmarshal(convertedJSON, &container); err != nil {
ch <- devContainerResult{nil, fmt.Errorf("error unmarshaling: %w", err)}
return
}
ch <- devContainerResult{&container, nil}
}()
return ch
}
func newPortsVisibilityCmd(app *App) *cobra.Command {
return &cobra.Command{
Use: "visibility <port>:{public|private|org}...",
Short: "Change the visibility of the forwarded port",
Example: "gh codespace ports visibility 80:org 3000:private 8000:public",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
codespace, err := cmd.Flags().GetString("codespace")
if err != nil {
// should only happen if flag is not defined
// or if the flag is not of string type
// since it's a persistent flag that we control it should never happen
return fmt.Errorf("get codespace flag: %w", err)
}
return app.UpdatePortVisibility(cmd.Context(), codespace, args)
},
}
}
func (a *App) UpdatePortVisibility(ctx context.Context, codespaceName string, args []string) (err error) {
ports, err := a.parsePortVisibilities(args)
if err != nil {
return fmt.Errorf("error parsing port arguments: %w", err)
}
codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName)
if err != nil {
if err == errNoCodespaces {
return err
}
return fmt.Errorf("error getting codespace: %w", err)
}
session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace)
if err != nil {
return fmt.Errorf("error connecting to codespace: %w", err)
}
defer safeClose(session, &err)
// TODO: check if port visibility can be updated in parallel instead of sequentially
for _, port := range ports {
a.StartProgressIndicatorWithLabel(fmt.Sprintf("Updating port %d visibility to: %s", port.number, port.visibility))
err := session.UpdateSharedServerPrivacy(ctx, port.number, port.visibility)
a.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error update port to public: %w", err)
}
}
return nil
}
type portVisibility struct {
number int
visibility string
}
func (a *App) parsePortVisibilities(args []string) ([]portVisibility, error) {
ports := make([]portVisibility, 0, len(args))
for _, a := range args {
fields := strings.Split(a, ":")
if len(fields) != 2 {
return nil, fmt.Errorf("invalid port visibility format for %q", a)
}
portStr, visibility := fields[0], fields[1]
portNumber, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("invalid port number: %w", err)
}
ports = append(ports, portVisibility{portNumber, visibility})
}
return ports, nil
}
// NewPortsForwardCmd returns a Cobra "ports forward" subcommand, which forwards a set of
// port pairs from the codespace to localhost.
func newPortsForwardCmd(app *App) *cobra.Command {
return &cobra.Command{
Use: "forward <remote-port>:<local-port>...",
Short: "Forward ports",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
codespace, err := cmd.Flags().GetString("codespace")
if err != nil {
// should only happen if flag is not defined
// or if the flag is not of string type
// since it's a persistent flag that we control it should never happen
return fmt.Errorf("get codespace flag: %w", err)
}
return app.ForwardPorts(cmd.Context(), codespace, args)
},
}
}
func (a *App) ForwardPorts(ctx context.Context, codespaceName string, ports []string) (err error) {
portPairs, err := getPortPairs(ports)
if err != nil {
return fmt.Errorf("get port pairs: %w", err)
}
codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName)
if err != nil {
if err == errNoCodespaces {
return err
}
return fmt.Errorf("error getting codespace: %w", err)
}
session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace)
if err != nil {
return fmt.Errorf("error connecting to codespace: %w", err)
}
defer safeClose(session, &err)
// Run forwarding of all ports concurrently, aborting all of
// them at the first failure, including cancellation of the context.
group, ctx := errgroup.WithContext(ctx)
for _, pair := range portPairs {
pair := pair
group.Go(func() error {
listen, err := net.Listen("tcp", fmt.Sprintf(":%d", pair.local))
if err != nil {
return err
}
defer listen.Close()
a.errLogger.Printf("Forwarding ports: remote %d <=> local %d", pair.remote, pair.local)
name := fmt.Sprintf("share-%d", pair.remote)
fwd := liveshare.NewPortForwarder(session, name, pair.remote, false)
return fwd.ForwardToListener(ctx, listen) // error always non-nil
})
}
return group.Wait() // first error
}
type portPair struct {
remote, local int
}
// getPortPairs parses a list of strings of form "%d:%d" into pairs of (remote, local) numbers.
func getPortPairs(ports []string) ([]portPair, error) {
pp := make([]portPair, 0, len(ports))
for _, portString := range ports {
parts := strings.Split(portString, ":")
if len(parts) < 2 {
return nil, fmt.Errorf("port pair: %q is not valid", portString)
}
remote, err := strconv.Atoi(parts[0])
if err != nil {
return pp, fmt.Errorf("convert remote port to int: %w", err)
}
local, err := strconv.Atoi(parts[1])
if err != nil {
return pp, fmt.Errorf("convert local port to int: %w", err)
}
pp = append(pp, portPair{remote, local})
}
return pp, nil
}
func normalizeJSON(j []byte) []byte {
// remove trailing commas
return bytes.ReplaceAll(j, []byte("},}"), []byte("}}"))
}

24
pkg/cmd/codespace/root.go Normal file
View file

@ -0,0 +1,24 @@
package codespace
import (
"github.com/spf13/cobra"
)
func NewRootCmd(app *App) *cobra.Command {
root := &cobra.Command{
Use: "codespace",
Short: "Connect to and manage your codespaces",
}
root.AddCommand(newCodeCmd(app))
root.AddCommand(newCreateCmd(app))
root.AddCommand(newDeleteCmd(app))
root.AddCommand(newListCmd(app))
root.AddCommand(newLogsCmd(app))
root.AddCommand(newPortsCmd(app))
root.AddCommand(newSSHCmd(app))
root.AddCommand(newCpCmd(app))
root.AddCommand(newStopCmd(app))
return root
}

483
pkg/cmd/codespace/ssh.go Normal file
View file

@ -0,0 +1,483 @@
package codespace
// This file defines the 'gh cs ssh' and 'gh cs cp' subcommands.
import (
"context"
"errors"
"fmt"
"io"
"io/ioutil"
"log"
"net"
"os"
"path/filepath"
"strings"
"sync"
"text/template"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/codespaces"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/liveshare"
"github.com/spf13/cobra"
)
type sshOptions struct {
codespace string
profile string
serverPort int
debug bool
debugFile string
stdio bool
config bool
scpArgs []string // scp arguments, for 'cs cp' (nil for 'cs ssh')
}
func newSSHCmd(app *App) *cobra.Command {
var opts sshOptions
sshCmd := &cobra.Command{
Use: "ssh [<flags>...] [-- <ssh-flags>...] [<command>]",
Short: "SSH into a codespace",
Long: heredoc.Doc(`
The 'ssh' command is used to SSH into a codespace. In its simplest form, you can
run 'gh cs ssh', select a codespace interactively, and connect.
The 'ssh' command also supports deeper integration with OpenSSH using a
'--config' option that generates per-codespace ssh configuration in OpenSSH
format. Including this configuration in your ~/.ssh/config improves the user
experience of tools that integrate with OpenSSH, such as bash/zsh completion of
ssh hostnames, remote path completion for scp/rsync/sshfs, git ssh remotes, and
so on.
Once that is set up (see the second example below), you can ssh to codespaces as
if they were ordinary remote hosts (using 'ssh', not 'gh cs ssh').
`),
Example: heredoc.Doc(`
$ gh codespace ssh
$ gh codespace ssh --config > ~/.ssh/codespaces
$ echo 'include ~/.ssh/codespaces' >> ~/.ssh/config'
`),
PreRunE: func(c *cobra.Command, args []string) error {
if opts.stdio {
if opts.codespace == "" {
return errors.New("`--stdio` requires explicit `--codespace`")
}
if opts.config {
return errors.New("cannot use `--stdio` with `--config`")
}
if opts.serverPort != 0 {
return errors.New("cannot use `--stdio` with `--server-port`")
}
if opts.profile != "" {
return errors.New("cannot use `--stdio` with `--profile`")
}
}
if opts.config {
if opts.profile != "" {
return errors.New("cannot use `--config` with `--profile`")
}
if opts.serverPort != 0 {
return errors.New("cannot use `--config` with `--server-port`")
}
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if opts.config {
return app.printOpenSSHConfig(cmd.Context(), opts)
} else {
return app.SSH(cmd.Context(), args, opts)
}
},
DisableFlagsInUseLine: true,
}
sshCmd.Flags().StringVarP(&opts.profile, "profile", "", "", "Name of the SSH profile to use")
sshCmd.Flags().IntVarP(&opts.serverPort, "server-port", "", 0, "SSH server port number (0 => pick unused)")
sshCmd.Flags().StringVarP(&opts.codespace, "codespace", "c", "", "Name of the codespace")
sshCmd.Flags().BoolVarP(&opts.debug, "debug", "d", false, "Log debug data to a file")
sshCmd.Flags().StringVarP(&opts.debugFile, "debug-file", "", "", "Path of the file log to")
sshCmd.Flags().BoolVarP(&opts.config, "config", "", false, "Write OpenSSH configuration to stdout")
sshCmd.Flags().BoolVar(&opts.stdio, "stdio", false, "Proxy sshd connection to stdio")
if err := sshCmd.Flags().MarkHidden("stdio"); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
}
return sshCmd
}
// SSH opens an ssh session or runs an ssh command in a codespace.
func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err error) {
// Ensure all child tasks (e.g. port forwarding) terminate before return.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// While connecting, ensure in the background that the user has keys installed.
// That lets us report a more useful error message if they don't.
authkeys := make(chan error, 1)
go func() {
authkeys <- checkAuthorizedKeys(ctx, a.apiClient)
}()
codespace, err := getOrChooseCodespace(ctx, a.apiClient, opts.codespace)
if err != nil {
return fmt.Errorf("get or choose codespace: %w", err)
}
liveshareLogger := noopLogger()
if opts.debug {
debugLogger, err := newFileLogger(opts.debugFile)
if err != nil {
return fmt.Errorf("error creating debug logger: %w", err)
}
defer safeClose(debugLogger, &err)
liveshareLogger = debugLogger.Logger
a.errLogger.Printf("Debug file located at: %s", debugLogger.Name())
}
session, err := codespaces.ConnectToLiveshare(ctx, a, liveshareLogger, a.apiClient, codespace)
if err != nil {
if authErr := <-authkeys; authErr != nil {
return authErr
}
return fmt.Errorf("error connecting to codespace: %w", err)
}
defer safeClose(session, &err)
a.StartProgressIndicatorWithLabel("Fetching SSH Details")
remoteSSHServerPort, sshUser, err := session.StartSSHServer(ctx)
a.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error getting ssh server details: %w", err)
}
if opts.stdio {
fwd := liveshare.NewPortForwarder(session, "sshd", remoteSSHServerPort, true)
stdio := newReadWriteCloser(os.Stdin, os.Stdout)
err := fwd.Forward(ctx, stdio) // always non-nil
return fmt.Errorf("tunnel closed: %w", err)
}
localSSHServerPort := opts.serverPort
usingCustomPort := localSSHServerPort != 0 // suppress log of command line in Shell
// Ensure local port is listening before client (Shell) connects.
// Unless the user specifies a server port, localSSHServerPort is 0
// and thus the client will pick a random port.
listen, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localSSHServerPort))
if err != nil {
return err
}
defer listen.Close()
localSSHServerPort = listen.Addr().(*net.TCPAddr).Port
connectDestination := opts.profile
if connectDestination == "" {
connectDestination = fmt.Sprintf("%s@localhost", sshUser)
}
tunnelClosed := make(chan error, 1)
go func() {
fwd := liveshare.NewPortForwarder(session, "sshd", remoteSSHServerPort, true)
tunnelClosed <- fwd.ForwardToListener(ctx, listen) // always non-nil
}()
shellClosed := make(chan error, 1)
go func() {
var err error
if opts.scpArgs != nil {
err = codespaces.Copy(ctx, opts.scpArgs, localSSHServerPort, connectDestination)
} else {
err = codespaces.Shell(ctx, a.errLogger, sshArgs, localSSHServerPort, connectDestination, usingCustomPort)
}
shellClosed <- err
}()
select {
case err := <-tunnelClosed:
return fmt.Errorf("tunnel closed: %w", err)
case err := <-shellClosed:
if err != nil {
return fmt.Errorf("shell closed: %w", err)
}
return nil // success
}
}
func (a *App) printOpenSSHConfig(ctx context.Context, opts sshOptions) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var err error
var csList []*api.Codespace
if opts.codespace == "" {
a.StartProgressIndicatorWithLabel("Fetching codespaces")
csList, err = a.apiClient.ListCodespaces(ctx, -1)
a.StopProgressIndicator()
} else {
var codespace *api.Codespace
codespace, err = getOrChooseCodespace(ctx, a.apiClient, opts.codespace)
csList = []*api.Codespace{codespace}
}
if err != nil {
return fmt.Errorf("error getting codespace info: %w", err)
}
type sshResult struct {
codespace *api.Codespace
user string // on success, the remote ssh username; else nil
err error
}
sshUsers := make(chan sshResult, len(csList))
var wg sync.WaitGroup
var status error
for _, cs := range csList {
if cs.State != "Available" && opts.codespace == "" {
fmt.Fprintf(os.Stderr, "skipping unavailable codespace %s: %s\n", cs.Name, cs.State)
status = cmdutil.SilentError
continue
}
cs := cs
wg.Add(1)
go func() {
result := sshResult{}
defer wg.Done()
session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, cs)
if err != nil {
result.err = fmt.Errorf("error connecting to codespace: %w", err)
} else {
defer session.Close()
_, result.user, err = session.StartSSHServer(ctx)
if err != nil {
result.err = fmt.Errorf("error getting ssh server details: %w", err)
} else {
result.codespace = cs
}
}
sshUsers <- result
}()
}
go func() {
wg.Wait()
close(sshUsers)
}()
// While the above fetches are running, ensure that the user has keys installed.
// That lets us report a more useful error message if they don't.
if err = checkAuthorizedKeys(ctx, a.apiClient); err != nil {
return err
}
t, err := template.New("ssh_config").Parse(heredoc.Doc(`
Host cs.{{.Name}}.{{.EscapedRef}}
User {{.SSHUser}}
ProxyCommand {{.GHExec}} cs ssh -c {{.Name}} --stdio
UserKnownHostsFile=/dev/null
StrictHostKeyChecking no
LogLevel quiet
ControlMaster auto
`))
if err != nil {
return fmt.Errorf("error formatting template: %w", err)
}
ghExec := a.executable.Executable()
for result := range sshUsers {
if result.err != nil {
fmt.Fprintf(os.Stderr, "%v\n", result.err)
status = cmdutil.SilentError
continue
}
// codespaceSSHConfig contains values needed to write an OpenSSH host
// configuration for a single codespace. For example:
//
// Host {{Name}}.{{EscapedRef}
// User {{SSHUser}
// ProxyCommand {{GHExec}} cs ssh -c {{Name}} --stdio
//
// EscapedRef is included in the name to help distinguish between codespaces
// when tab-completing ssh hostnames. '/' characters in EscapedRef are
// flattened to '-' to prevent problems with tab completion or when the
// hostname appears in ControlMaster socket paths.
type codespaceSSHConfig struct {
Name string // the codespace name, passed to `ssh -c`
EscapedRef string // the currently checked-out branch
SSHUser string // the remote ssh username
GHExec string // path used for invoking the current `gh` binary
}
conf := codespaceSSHConfig{
Name: result.codespace.Name,
EscapedRef: strings.ReplaceAll(result.codespace.GitStatus.Ref, "/", "-"),
SSHUser: result.user,
GHExec: ghExec,
}
if err := t.Execute(a.io.Out, conf); err != nil {
return err
}
}
return status
}
type cpOptions struct {
sshOptions
recursive bool // -r
expand bool // -e
}
func newCpCmd(app *App) *cobra.Command {
var opts cpOptions
cpCmd := &cobra.Command{
Use: "cp [-e] [-r] <sources>... <dest>",
Short: "Copy files between local and remote file systems",
Long: heredoc.Docf(`
The cp command copies files between the local and remote file systems.
As with the UNIX %[1]scp%[1]s command, the first argument specifies the source and the last
specifies the destination; additional sources may be specified after the first,
if the destination is a directory.
The %[1]s--recursive%[1]s flag is required if any source is a directory.
A "remote:" prefix on any file name argument indicates that it refers to
the file system of the remote (Codespace) machine. It is resolved relative
to the home directory of the remote user.
By default, remote file names are interpreted literally. With the %[1]s--expand%[1]s flag,
each such argument is treated in the manner of %[1]sscp%[1]s, as a Bash expression to
be evaluated on the remote machine, subject to expansion of tildes, braces, globs,
environment variables, and backticks. For security, do not use this flag with arguments
provided by untrusted users; see <https://lwn.net/Articles/835962/> for discussion.
`, "`"),
Example: heredoc.Doc(`
$ gh codespace cp -e README.md 'remote:/workspaces/$RepositoryName/'
$ gh codespace cp -e 'remote:~/*.go' ./gofiles/
$ gh codespace cp -e 'remote:/workspaces/myproj/go.{mod,sum}' ./gofiles/
`),
RunE: func(cmd *cobra.Command, args []string) error {
return app.Copy(cmd.Context(), args, opts)
},
DisableFlagsInUseLine: true,
}
// We don't expose all sshOptions.
cpCmd.Flags().BoolVarP(&opts.recursive, "recursive", "r", false, "Recursively copy directories")
cpCmd.Flags().BoolVarP(&opts.expand, "expand", "e", false, "Expand remote file names on remote shell")
cpCmd.Flags().StringVarP(&opts.codespace, "codespace", "c", "", "Name of the codespace")
return cpCmd
}
// Copy copies files between the local and remote file systems.
// The mechanics are similar to 'ssh' but using 'scp'.
func (a *App) Copy(ctx context.Context, args []string, opts cpOptions) error {
if len(args) < 2 {
return fmt.Errorf("cp requires source and destination arguments")
}
if opts.recursive {
opts.scpArgs = append(opts.scpArgs, "-r")
}
opts.scpArgs = append(opts.scpArgs, "--")
hasRemote := false
for _, arg := range args {
if rest := strings.TrimPrefix(arg, "remote:"); rest != arg {
hasRemote = true
// scp treats each filename argument as a shell expression,
// subjecting it to expansion of environment variables, braces,
// tilde, backticks, globs and so on. Because these present a
// security risk (see https://lwn.net/Articles/835962/), we
// disable them by shell-escaping the argument unless the user
// provided the -e flag.
if !opts.expand {
arg = `remote:'` + strings.Replace(rest, `'`, `'\''`, -1) + `'`
}
} else if !filepath.IsAbs(arg) {
// scp treats a colon in the first path segment as a host identifier.
// Escape it by prepending "./".
// TODO(adonovan): test on Windows, including with a c:\\foo path.
const sep = string(os.PathSeparator)
first := strings.Split(filepath.ToSlash(arg), sep)[0]
if strings.Contains(first, ":") {
arg = "." + sep + arg
}
}
opts.scpArgs = append(opts.scpArgs, arg)
}
if !hasRemote {
return cmdutil.FlagErrorf("at least one argument must have a 'remote:' prefix")
}
return a.SSH(ctx, nil, opts.sshOptions)
}
// fileLogger is a wrapper around an log.Logger configured to write
// to a file. It exports two additional methods to get the log file name
// and close the file handle when the operation is finished.
type fileLogger struct {
*log.Logger
f *os.File
}
// newFileLogger creates a new fileLogger. It returns an error if the file
// cannot be created. The file is created on the specified path, if the path
// is empty it is created in the temporary directory.
func newFileLogger(file string) (fl *fileLogger, err error) {
var f *os.File
if file == "" {
f, err = ioutil.TempFile("", "")
if err != nil {
return nil, fmt.Errorf("failed to create tmp file: %w", err)
}
} else {
f, err = os.Create(file)
if err != nil {
return nil, err
}
}
return &fileLogger{
Logger: log.New(f, "", log.LstdFlags),
f: f,
}, nil
}
func (fl *fileLogger) Name() string {
return fl.f.Name()
}
func (fl *fileLogger) Close() error {
return fl.f.Close()
}
type combinedReadWriteCloser struct {
io.ReadCloser
io.WriteCloser
}
func newReadWriteCloser(reader io.ReadCloser, writer io.WriteCloser) io.ReadWriteCloser {
return &combinedReadWriteCloser{reader, writer}
}
func (crwc *combinedReadWriteCloser) Close() error {
werr := crwc.WriteCloser.Close()
rerr := crwc.ReadCloser.Close()
if werr != nil {
return werr
}
return rerr
}

73
pkg/cmd/codespace/stop.go Normal file
View file

@ -0,0 +1,73 @@
package codespace
import (
"context"
"errors"
"fmt"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/spf13/cobra"
)
func newStopCmd(app *App) *cobra.Command {
var codespace string
stopCmd := &cobra.Command{
Use: "stop",
Short: "Stop a running codespace",
Args: noArgsConstraint,
RunE: func(cmd *cobra.Command, args []string) error {
return app.StopCodespace(cmd.Context(), codespace)
},
}
stopCmd.Flags().StringVarP(&codespace, "codespace", "c", "", "Name of the codespace")
return stopCmd
}
func (a *App) StopCodespace(ctx context.Context, codespaceName string) error {
if codespaceName == "" {
a.StartProgressIndicatorWithLabel("Fetching codespaces")
codespaces, err := a.apiClient.ListCodespaces(ctx, -1)
a.StopProgressIndicator()
if err != nil {
return fmt.Errorf("failed to list codespaces: %w", err)
}
var runningCodespaces []*api.Codespace
for _, c := range codespaces {
cs := codespace{c}
if cs.running() {
runningCodespaces = append(runningCodespaces, c)
}
}
if len(runningCodespaces) == 0 {
return errors.New("no running codespaces")
}
codespace, err := chooseCodespaceFromList(ctx, runningCodespaces)
if err != nil {
return fmt.Errorf("failed to choose codespace: %w", err)
}
codespaceName = codespace.Name
} else {
a.StartProgressIndicatorWithLabel("Fetching codespace")
c, err := a.apiClient.GetCodespace(ctx, codespaceName, false)
a.StopProgressIndicator()
if err != nil {
return fmt.Errorf("failed to get codespace: %q: %w", codespaceName, err)
}
cs := codespace{c}
if !cs.running() {
return fmt.Errorf("codespace %q is not running", codespaceName)
}
}
a.StartProgressIndicatorWithLabel("Stopping codespace")
defer a.StopProgressIndicator()
if err := a.apiClient.StopCodespace(ctx, codespaceName); err != nil {
return fmt.Errorf("failed to stop codespace: %w", err)
}
return nil
}

View file

@ -1,7 +1,6 @@
package completion
import (
"errors"
"fmt"
"github.com/MakeNowJust/heredoc"
@ -68,7 +67,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
RunE: func(cmd *cobra.Command, args []string) error {
if shellType == "" {
if io.IsStdoutTTY() {
return &cmdutil.FlagError{Err: errors.New("error: the value for `--shell` is required")}
return cmdutil.FlagErrorf("error: the value for `--shell` is required")
}
shellType = "bash"
}

View file

@ -6,6 +6,7 @@ import (
"github.com/cli/cli/v2/internal/config"
cmdGet "github.com/cli/cli/v2/pkg/cmd/config/get"
cmdList "github.com/cli/cli/v2/pkg/cmd/config/list"
cmdSet "github.com/cli/cli/v2/pkg/cmd/config/set"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
@ -33,6 +34,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(cmdGet.NewCmdConfigGet(f, nil))
cmd.AddCommand(cmdSet.NewCmdConfigSet(f, nil))
cmd.AddCommand(cmdList.NewCmdConfigList(f, nil))
return cmd
}

View file

@ -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
}

Some files were not shown because too many files have changed in this diff Show more