diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..eac1bad83 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,5 @@ +{ + "extensions": [ + "golang.go" + ] +} \ No newline at end of file diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 720f1210e..034253caf 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -10,6 +10,11 @@ on: schedule: - cron: "0 0 * * 0" +permissions: + actions: read # for github/codeql-action/init to get workflow details + contents: read # for actions/checkout to fetch code + security-events: write # for github/codeql-action/analyze to upload SARIF results + jobs: CodeQL-Build: runs-on: ubuntu-latest diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f3f5502ea..e92122ece 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,5 +1,9 @@ name: Tests on: [push, pull_request] + +permissions: + contents: read + jobs: build: strategy: @@ -17,14 +21,13 @@ jobs: - name: Check out code uses: actions/checkout@v3 - - name: Cache Go modules - uses: actions/cache@v2 + - name: Restore Go modules cache + uses: actions/cache@v3 with: - path: ~/go - key: ${{ runner.os }}-build-${{ hashFiles('go.mod') }} + path: ~/go/pkg/mod + key: go-${{ runner.os }}-${{ hashFiles('go.mod') }} restore-keys: | - ${{ runner.os }}-build- - ${{ runner.os }}- + go-${{ runner.os }}- - name: Download dependencies run: go mod download diff --git a/.github/workflows/issueauto.yml b/.github/workflows/issueauto.yml index a366d6ed8..40c4b36e7 100644 --- a/.github/workflows/issueauto.yml +++ b/.github/workflows/issueauto.yml @@ -2,16 +2,21 @@ name: Issue Automation on: issues: types: [opened] + +permissions: + contents: none + issues: write + 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 }} + 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 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 90e2c67e0..7e093c285 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -11,6 +11,9 @@ on: - go.mod - go.sum +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest @@ -24,12 +27,20 @@ jobs: - name: Check out code uses: actions/checkout@v3 + - name: Restore Go modules cache + uses: actions/cache@v3 + with: + path: ~/go/pkg/mod + key: go-${{ runner.os }}-${{ hashFiles('go.mod') }} + restore-keys: | + go-${{ runner.os }}- + - name: Verify dependencies run: | go mod verify go mod download - LINT_VERSION=1.39.0 + LINT_VERSION=1.44.2 curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \ tar xz --strip-components 1 --wildcards \*/golangci-lint mkdir -p bin && mv golangci-lint bin/ diff --git a/.github/workflows/prauto.yml b/.github/workflows/prauto.yml index 047fb52ea..2596fa76c 100644 --- a/.github/workflows/prauto.yml +++ b/.github/workflows/prauto.yml @@ -2,6 +2,12 @@ name: PR Automation on: pull_request_target: types: [ready_for_review, opened, reopened] + +permissions: + contents: none + issues: write + pull-requests: write + jobs: pr-auto: runs-on: ubuntu-latest diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 3e7f6cf2f..5e570350a 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -5,6 +5,10 @@ on: tags: - "v*" +permissions: + contents: write # publishing releases + repository-projects: write # move cards between columns + jobs: goreleaser: runs-on: ubuntu-latest diff --git a/api/queries_repo.go b/api/queries_repo.go index b7ea14915..a287b6557 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -46,6 +46,7 @@ type Repository struct { MergeCommitAllowed bool SquashMergeAllowed bool RebaseMergeAllowed bool + AutoMergeAllowed bool ForkCount int StargazerCount int @@ -68,6 +69,7 @@ type Repository struct { IsArchived bool IsEmpty bool IsFork bool + ForkingAllowed bool IsInOrganization bool IsMirror bool IsPrivate bool @@ -81,6 +83,7 @@ type Repository struct { ViewerPermission string ViewerPossibleCommitEmails []string ViewerSubscription string + Visibility string RepositoryTopics struct { Nodes []struct { diff --git a/git/git.go b/git/git.go index 7a3a81437..31fea6233 100644 --- a/git/git.go +++ b/git/git.go @@ -307,6 +307,15 @@ func CheckoutBranch(branch string) error { return run.PrepareCmd(configCmd).Run() } +func CheckoutNewBranch(remoteName, branch string) error { + track := fmt.Sprintf("%s/%s", remoteName, branch) + configCmd, err := GitCommand("checkout", "-b", branch, "--track", track) + if err != nil { + return err + } + 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) diff --git a/go.mod b/go.mod index 59cd5006d..e34008e52 100644 --- a/go.mod +++ b/go.mod @@ -33,13 +33,14 @@ require ( github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b 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/cobra v1.4.0 github.com/spf13/pflag v1.0.5 - github.com/stretchr/testify v1.7.0 + github.com/stretchr/testify v1.7.1 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 + golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect golang.org/x/sync v0.0.0-20210220032951-036812b2e83c golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 - golang.org/x/term v0.0.0-20210503060354-a79de5458b56 + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b ) diff --git a/go.sum b/go.sum index b91f9192a..5ad634436 100644 --- a/go.sum +++ b/go.sum @@ -13,20 +13,6 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= -cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= -cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= -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= @@ -35,7 +21,6 @@ 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.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= @@ -50,44 +35,22 @@ github.com/AlecAivazis/survey/v2 v2.3.2 h1:TqTB+aDDCLYhf9/bD2TwSO8u8jDSmMUd2SUVO github.com/AlecAivazis/survey/v2 v2.3.2/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw= github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc= -github.com/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/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY= github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/glamour v0.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= @@ -101,17 +64,6 @@ github.com/cli/shurcooL-graphql v0.0.1 h1:/9J3t9O6p1B8zdBBtQighq5g7DQRItBwuwGh3S 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.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= @@ -124,38 +76,18 @@ github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k 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/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= +github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= -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= @@ -163,8 +95,6 @@ github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= 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= @@ -177,13 +107,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -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= @@ -193,18 +118,11 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -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/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= @@ -212,69 +130,29 @@ github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= -github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -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/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0= -github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM= github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY= github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= -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-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= -github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= -github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU= -github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4= -github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= -github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw= github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/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.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/gojq v0.12.7 h1:hYPTpeWfrJ1OT+2j6cvBScbhl0TkdwGM4bc66onUSOQ= @@ -283,23 +161,13 @@ github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921i 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/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/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/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= @@ -307,20 +175,10 @@ 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.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= -github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= -github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= -github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= -github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= @@ -328,123 +186,60 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m 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.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y= github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= -github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= -github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso= -github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= -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/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.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/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/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/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.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/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b h1:0/ecDXh/HTHRtSDSFnD2/Ta1yQ5J76ZspVY4u0/jGFk= github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a h1:KikTa6HtAK8cS1qjvUvvq4QO21QnwC+EfvB+OAuZ/ZU= 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.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/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q= +github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g= 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.10.0/go.mod h1:SoyBPwAtKDzypXNDFKN5kzH7ppppbGZtls1UpIy5AsM= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= 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= github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/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/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.4 h1:zNWRjYUW32G9KirMXYHQHVNFkXvMI7LpgNW2AgYAoIs= github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg= github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os= github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ= -go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs= -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= 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/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= @@ -467,8 +262,6 @@ golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHl golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= -golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= @@ -477,25 +270,17 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 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-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -510,39 +295,19 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -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-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/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk= +golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 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= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= -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-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/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM= +golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= 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= @@ -551,35 +316,24 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 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-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= @@ -591,47 +345,24 @@ golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -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-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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 h1:nhht2DYV/Sn3qOayu8lM+cU1ii9sTLUeBQwQQfUHtrs= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w= golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/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= @@ -651,7 +382,6 @@ 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-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= @@ -676,22 +406,9 @@ golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= -golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= -golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -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= @@ -713,30 +430,13 @@ google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0M google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= -google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= -google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= -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.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= google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= @@ -760,46 +460,12 @@ google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfG google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= -google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= -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= @@ -812,22 +478,6 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= -google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= -google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= -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= @@ -837,24 +487,13 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= 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/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.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= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index afed04404..e8c76e6be 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -52,6 +52,13 @@ const ( vscsAPI = "https://online.visualstudio.com" ) +const ( + VSCSTargetLocal = "local" + VSCSTargetDevelopment = "development" + VSCSTargetPPE = "ppe" + VSCSTargetProduction = "production" +) + // API is the interface to the codespace service. type API struct { client httpClient @@ -160,17 +167,22 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error } // Codespace represents a codespace. +// You can see more about the fields in this type in the codespaces api docs: +// https://docs.github.com/en/rest/reference/codespaces type Codespace struct { - Name string `json:"name"` - CreatedAt string `json:"created_at"` - DisplayName string `json:"display_name"` - LastUsedAt string `json:"last_used_at"` - Owner User `json:"owner"` - Repository Repository `json:"repository"` - State string `json:"state"` - GitStatus CodespaceGitStatus `json:"git_status"` - Connection CodespaceConnection `json:"connection"` - Machine CodespaceMachine `json:"machine"` + Name string `json:"name"` + CreatedAt string `json:"created_at"` + DisplayName string `json:"display_name"` + LastUsedAt string `json:"last_used_at"` + Owner User `json:"owner"` + Repository Repository `json:"repository"` + State string `json:"state"` + GitStatus CodespaceGitStatus `json:"git_status"` + Connection CodespaceConnection `json:"connection"` + Machine CodespaceMachine `json:"machine"` + VSCSTarget string `json:"vscs_target"` + PendingOperation bool `json:"pending_operation"` + PendingOperationDisabledReason string `json:"pending_operation_disabled_reason"` } type CodespaceGitStatus struct { @@ -218,6 +230,7 @@ var CodespaceFields = []string{ "createdAt", "lastUsedAt", "machineName", + "vscsTarget", } func (c *Codespace) ExportData(fields []string) map[string]interface{} { @@ -238,6 +251,10 @@ func (c *Codespace) ExportData(fields []string) map[string]interface{} { "hasUnpushedChanges": c.GitStatus.HasUnpushedChanges, "hasUncommitedChanges": c.GitStatus.HasUncommitedChanges, } + case "vscsTarget": + if c.VSCSTarget != "" && c.VSCSTarget != VSCSTargetProduction { + data[f] = c.VSCSTarget + } default: sf := v.FieldByNameFunc(func(s string) bool { return strings.EqualFold(f, s) @@ -579,6 +596,8 @@ type CreateCodespaceParams struct { Branch string Machine string Location string + VSCSTarget string + VSCSTargetURL string PermissionsOptOut bool } @@ -625,6 +644,8 @@ type startCreateRequest struct { Ref string `json:"ref"` Location string `json:"location"` Machine string `json:"machine"` + VSCSTarget string `json:"vscs_target,omitempty"` + VSCSTargetURL string `json:"vscs_target_url,omitempty"` PermissionsOptOut bool `json:"devcontainer_permissions_opt_out"` } @@ -654,8 +675,11 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (* Ref: params.Branch, Location: params.Location, Machine: params.Machine, + VSCSTarget: params.VSCSTarget, + VSCSTargetURL: params.VSCSTargetURL, PermissionsOptOut: params.PermissionsOptOut, }) + if err != nil { return nil, fmt.Errorf("error marshaling request: %w", err) } @@ -761,6 +785,20 @@ func (a *API) EditCodespace(ctx context.Context, codespaceName string, params *E defer resp.Body.Close() if resp.StatusCode != http.StatusOK { + // 422 (unprocessable entity) is likely caused by the codespace having a + // pending op, so we'll fetch the codespace to see if that's the case + // and return a more understandable error message. + if resp.StatusCode == http.StatusUnprocessableEntity { + pendingOp, reason, err := a.checkForPendingOperation(ctx, codespaceName) + // If there's an error or there's not a pending op, we want to let + // this fall through to the normal api.HandleHTTPError flow + if err == nil && pendingOp { + return nil, fmt.Errorf( + "codespace is disabled while it has a pending operation: %s", + reason, + ) + } + } return nil, api.HandleHTTPError(resp) } @@ -777,6 +815,14 @@ func (a *API) EditCodespace(ctx context.Context, codespaceName string, params *E return &response, nil } +func (a *API) checkForPendingOperation(ctx context.Context, codespaceName string) (bool, string, error) { + codespace, err := a.GetCodespace(ctx, codespaceName, false) + if err != nil { + return false, "", err + } + return codespace.PendingOperation, codespace.PendingOperationDisabledReason, nil +} + type getCodespaceRepositoryContentsResponse struct { Content string `json:"content"` } diff --git a/internal/codespaces/api/api_test.go b/internal/codespaces/api/api_test.go index ebbbe5209..81eb563c3 100644 --- a/internal/codespaces/api/api_test.go +++ b/internal/codespaces/api/api_test.go @@ -388,6 +388,7 @@ func createFakeEditServer(t *testing.T, codespaceName string) *httptest.Server { fmt.Fprint(w, string(responseData)) })) } + func TestAPI_EditCodespace(t *testing.T) { type args struct { ctx context.Context @@ -434,3 +435,41 @@ func TestAPI_EditCodespace(t *testing.T) { }) } } + +func createFakeEditPendingOpServer(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPatch { + w.WriteHeader(http.StatusUnprocessableEntity) + return + } + + if r.Method == http.MethodGet { + response := Codespace{ + PendingOperation: true, + PendingOperationDisabledReason: "Some pending operation", + } + + responseData, _ := json.Marshal(response) + fmt.Fprint(w, string(responseData)) + return + } + })) +} + +func TestAPI_EditCodespacePendingOperation(t *testing.T) { + svr := createFakeEditPendingOpServer(t) + defer svr.Close() + + a := &API{ + client: &http.Client{}, + githubAPI: svr.URL, + } + + _, err := a.EditCodespace(context.Background(), "disabledCodespace", &EditCodespaceParams{DisplayName: "some silly name"}) + if err == nil { + t.Error("Expected pending operation error, but got nothing") + } + if err.Error() != "codespace is disabled while it has a pending operation: Some pending operation" { + t.Errorf("Expected pending operation error, but got %v", err) + } +} diff --git a/pkg/cmd/codespace/code.go b/pkg/cmd/codespace/code.go index f80e32527..0942d15d3 100644 --- a/pkg/cmd/codespace/code.go +++ b/pkg/cmd/codespace/code.go @@ -31,18 +31,12 @@ func newCodeCmd(app *App) *cobra.Command { // VSCode opens a codespace in the local VS VSCode application. func (a *App) VSCode(ctx context.Context, codespaceName string, useInsiders bool) error { - 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 + codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName) + if err != nil { + return err } - url := vscodeProtocolURL(codespaceName, useInsiders) + url := vscodeProtocolURL(codespace.Name, useInsiders) if err := a.browser.Browse(url); err != nil { return fmt.Errorf("error opening Visual Studio Code: %w", err) } diff --git a/pkg/cmd/codespace/code_test.go b/pkg/cmd/codespace/code_test.go index cbc59864a..30f06bd39 100644 --- a/pkg/cmd/codespace/code_test.go +++ b/pkg/cmd/codespace/code_test.go @@ -4,7 +4,9 @@ import ( "context" "testing" + "github.com/cli/cli/v2/internal/codespaces/api" "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" ) func TestApp_VSCode(t *testing.T) { @@ -41,7 +43,8 @@ func TestApp_VSCode(t *testing.T) { t.Run(tt.name, func(t *testing.T) { b := &cmdutil.TestBrowser{} a := &App{ - browser: b, + browser: b, + apiClient: testCodeApiMock(), } if err := a.VSCode(context.Background(), tt.args.codespaceName, tt.args.useInsiders); (err != nil) != tt.wantErr { t.Errorf("App.VSCode() error = %v, wantErr %v", err, tt.wantErr) @@ -50,3 +53,46 @@ func TestApp_VSCode(t *testing.T) { }) } } + +func TestPendingOperationDisallowsCode(t *testing.T) { + app := testingCodeApp() + + if err := app.VSCode(context.Background(), "disabledCodespace", false); err != nil { + if err.Error() != "codespace is disabled while it has a pending operation: Some pending operation" { + t.Errorf("expected pending operation error, but got: %v", err) + } + } else { + t.Error("expected pending operation error, but got nothing") + } +} + +func testingCodeApp() *App { + io, _, _, _ := iostreams.Test() + return NewApp(io, nil, testCodeApiMock(), nil) +} + +func testCodeApiMock() *apiClientMock { + user := &api.User{Login: "monalisa"} + testingCodespace := &api.Codespace{ + Name: "monalisa-cli-cli-abcdef", + } + disabledCodespace := &api.Codespace{ + Name: "disabledCodespace", + PendingOperation: true, + PendingOperationDisabledReason: "Some pending operation", + } + return &apiClientMock{ + GetCodespaceFunc: func(_ context.Context, name string, _ bool) (*api.Codespace, error) { + if name == "disabledCodespace" { + return disabledCodespace, nil + } + return testingCodespace, nil + }, + GetUserFunc: func(_ context.Context) (*api.User, error) { + return user, nil + }, + AuthorizedKeysFunc: func(_ context.Context, _ string) ([]byte, error) { + return []byte{}, nil + }, + } +} diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go index 6061eac78..aec386f6c 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -183,6 +183,13 @@ func getOrChooseCodespace(ctx context.Context, apiClient apiClient, codespaceNam } } + if codespace.PendingOperation { + return nil, fmt.Errorf( + "codespace is disabled while it has a pending operation: %s", + codespace.PendingOperationDisabledReason, + ) + } + return codespace, nil } diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go index b145a6a7d..81bb57bc4 100644 --- a/pkg/cmd/codespace/create.go +++ b/pkg/cmd/codespace/create.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "time" "github.com/AlecAivazis/survey/v2" @@ -47,7 +48,13 @@ func newCreateCmd(app *App) *cobra.Command { // Create creates a new Codespace func (a *App) Create(ctx context.Context, opts createOptions) error { - locationCh := getLocation(ctx, a.apiClient) + + // Overrides for Codespace developers to target test environments + vscsLocation := os.Getenv("VSCS_LOCATION") + vscsTarget := os.Getenv("VSCS_TARGET") + vscsTargetUrl := os.Getenv("VSCS_TARGET_URL") + + locationCh := getLocation(ctx, vscsLocation, a.apiClient) userInputs := struct { Repository string @@ -117,6 +124,8 @@ func (a *App) Create(ctx context.Context, opts createOptions) error { Branch: branch, Machine: machine, Location: locationResult.Location, + VSCSTarget: vscsTarget, + VSCSTargetURL: vscsTargetUrl, IdleTimeoutMinutes: int(opts.idleTimeout.Minutes()), PermissionsOptOut: opts.permissionsOptOut, } @@ -164,7 +173,7 @@ func (a *App) handleAdditionalPermissions(ctx context.Context, createParams *api } choices := []string{ - "Continue in browser to review and authorize additional permissions", + "Continue in browser to review and authorize additional permissions (Recommended)", "Continue without authorizing additional permissions", } @@ -190,6 +199,7 @@ func (a *App) handleAdditionalPermissions(ctx context.Context, createParams *api // if the user chose to continue in the browser, open the URL if answers.Accept == choices[0] { + fmt.Fprintln(a.io.ErrOut, "Please re-run the create request after accepting permissions in the browser.") if err := a.browser.Browse(allowPermissionsURL); err != nil { return nil, fmt.Errorf("error opening browser: %w", err) } @@ -282,9 +292,18 @@ type locationResult struct { Err error } -// getLocation fetches the closest Codespace datacenter region/location to the user. -func getLocation(ctx context.Context, apiClient apiClient) <-chan locationResult { +// getLocation fetches the closest Codespace datacenter +// region/location to the user, unless the 'vscsLocationOverride' override is set +func getLocation(ctx context.Context, vscsLocationOverride string, apiClient apiClient) <-chan locationResult { ch := make(chan locationResult, 1) + + // Developer override is set, return the override + if vscsLocationOverride != "" { + ch <- locationResult{vscsLocationOverride, nil} + return ch + } + + // Dynamically fetch the region location go func() { location, err := apiClient.GetCodespaceRegionLocation(ctx) ch <- locationResult{location, err} diff --git a/pkg/cmd/codespace/list.go b/pkg/cmd/codespace/list.go index 661d1da1c..41f44d299 100644 --- a/pkg/cmd/codespace/list.go +++ b/pkg/cmd/codespace/list.go @@ -43,6 +43,14 @@ func (a *App) List(ctx context.Context, limit int, exporter cmdutil.Exporter) er return fmt.Errorf("error getting codespaces: %w", err) } + hasNonProdVSCSTarget := false + for _, apiCodespace := range codespaces { + if apiCodespace.VSCSTarget != "" && apiCodespace.VSCSTarget != api.VSCSTargetProduction { + hasNonProdVSCSTarget = true + break + } + } + if err := a.io.StartPager(); err != nil { a.errLogger.Printf("error starting pager: %v", err) } @@ -59,6 +67,11 @@ func (a *App) List(ctx context.Context, limit int, exporter cmdutil.Exporter) er tp.AddField("BRANCH", nil, nil) tp.AddField("STATE", nil, nil) tp.AddField("CREATED AT", nil, nil) + + if hasNonProdVSCSTarget { + tp.AddField("VSCS TARGET", nil, nil) + } + tp.EndRow() } @@ -74,10 +87,24 @@ func (a *App) List(ctx context.Context, limit int, exporter cmdutil.Exporter) er stateColor = cs.Green } - tp.AddField(c.Name, nil, cs.Yellow) + formattedName := formatNameForVSCSTarget(c.Name, c.VSCSTarget) + + var nameColor func(string) string + switch c.PendingOperation { + case false: + nameColor = cs.Yellow + case true: + nameColor = cs.Gray + } + + tp.AddField(formattedName, nil, nameColor) tp.AddField(c.Repository.FullName, nil, nil) tp.AddField(c.branchWithGitStatus(), nil, cs.Cyan) - tp.AddField(c.State, nil, stateColor) + if c.PendingOperation { + tp.AddField(c.PendingOperationDisabledReason, nil, nameColor) + } else { + tp.AddField(c.State, nil, stateColor) + } if tp.IsTTY() { ct, err := time.Parse(time.RFC3339, c.CreatedAt) @@ -88,8 +115,25 @@ func (a *App) List(ctx context.Context, limit int, exporter cmdutil.Exporter) er } else { tp.AddField(c.CreatedAt, nil, nil) } + + if hasNonProdVSCSTarget { + tp.AddField(c.VSCSTarget, nil, nil) + } + tp.EndRow() } return tp.Render() } + +func formatNameForVSCSTarget(name, vscsTarget string) string { + if vscsTarget == api.VSCSTargetDevelopment || vscsTarget == api.VSCSTargetLocal { + return fmt.Sprintf("%s 🚧", name) + } + + if vscsTarget == api.VSCSTargetPPE { + return fmt.Sprintf("%s ✨", name) + } + + return name +} diff --git a/pkg/cmd/codespace/logs.go b/pkg/cmd/codespace/logs.go index d0a0c233b..6feab3080 100644 --- a/pkg/cmd/codespace/logs.go +++ b/pkg/cmd/codespace/logs.go @@ -38,7 +38,7 @@ func (a *App) Logs(ctx context.Context, codespaceName string, follow bool) (err codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName) if err != nil { - return fmt.Errorf("get or choose codespace: %w", err) + return err } authkeys := make(chan error, 1) diff --git a/pkg/cmd/codespace/logs_test.go b/pkg/cmd/codespace/logs_test.go new file mode 100644 index 000000000..1eaf2e9d9 --- /dev/null +++ b/pkg/cmd/codespace/logs_test.go @@ -0,0 +1,47 @@ +package codespace + +import ( + "context" + "testing" + + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/iostreams" +) + +func TestPendingOperationDisallowsLogs(t *testing.T) { + app := testingLogsApp() + + if err := app.Logs(context.Background(), "disabledCodespace", false); err != nil { + if err.Error() != "codespace is disabled while it has a pending operation: Some pending operation" { + t.Errorf("expected pending operation error, but got: %v", err) + } + } else { + t.Error("expected pending operation error, but got nothing") + } +} + +func testingLogsApp() *App { + user := &api.User{Login: "monalisa"} + disabledCodespace := &api.Codespace{ + Name: "disabledCodespace", + PendingOperation: true, + PendingOperationDisabledReason: "Some pending operation", + } + apiMock := &apiClientMock{ + GetCodespaceFunc: func(_ context.Context, name string, _ bool) (*api.Codespace, error) { + if name == "disabledCodespace" { + return disabledCodespace, nil + } + return nil, nil + }, + GetUserFunc: func(_ context.Context) (*api.User, error) { + return user, nil + }, + AuthorizedKeysFunc: func(_ context.Context, _ string) ([]byte, error) { + return []byte{}, nil + }, + } + + io, _, _, _ := iostreams.Test() + return NewApp(io, nil, apiMock, nil) +} diff --git a/pkg/cmd/codespace/ports.go b/pkg/cmd/codespace/ports.go index 094833e30..f8662ae37 100644 --- a/pkg/cmd/codespace/ports.go +++ b/pkg/cmd/codespace/ports.go @@ -7,8 +7,10 @@ import ( "errors" "fmt" "net" + "net/http" "strconv" "strings" + "time" "github.com/cli/cli/v2/internal/codespaces" "github.com/cli/cli/v2/internal/codespaces/api" @@ -48,11 +50,7 @@ func newPortsCmd(app *App) *cobra.Command { 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) + return err } devContainerCh := getDevContainer(ctx, a.apiClient, codespace) @@ -229,6 +227,30 @@ func newPortsVisibilityCmd(app *App) *cobra.Command { } } +type ErrUpdatingPortVisibility struct { + port int + visibility string + err error +} + +func newErrUpdatingPortVisibility(port int, visibility string, err error) *ErrUpdatingPortVisibility { + return &ErrUpdatingPortVisibility{ + port: port, + visibility: visibility, + err: err, + } +} + +func (e *ErrUpdatingPortVisibility) Error() string { + return fmt.Sprintf("error waiting for port %d to update to %s: %s", e.port, e.visibility, e.err) +} + +func (e *ErrUpdatingPortVisibility) Unwrap() error { + return e.err +} + +var errUpdatePortVisibilityForbidden = errors.New("organization admin has forbidden this privacy setting") + func (a *App) UpdatePortVisibility(ctx context.Context, codespaceName string, args []string) (err error) { ports, err := a.parsePortVisibilities(args) if err != nil { @@ -237,10 +259,7 @@ func (a *App) UpdatePortVisibility(ctx context.Context, codespaceName string, ar codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName) if err != nil { - if err == errNoCodespaces { - return err - } - return fmt.Errorf("error getting codespace: %w", err) + return err } session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace) @@ -252,11 +271,43 @@ func (a *App) UpdatePortVisibility(ctx context.Context, codespaceName string, ar // 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) + + // wait for success or failure + g, ctx := errgroup.WithContext(ctx) + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + g.Go(func() error { + updateNotif, err := session.WaitForPortNotification(ctx, port.number, liveshare.PortChangeKindUpdate) + if err != nil { + return fmt.Errorf("error waiting for port %d to update: %w", port.number, err) + + } + if !updateNotif.Success { + if updateNotif.StatusCode == http.StatusForbidden { + return newErrUpdatingPortVisibility(port.number, port.visibility, errUpdatePortVisibilityForbidden) + } + return newErrUpdatingPortVisibility(port.number, port.visibility, errors.New(updateNotif.ErrorDetail)) + + } + return nil // success + }) + + g.Go(func() error { + err := session.UpdateSharedServerPrivacy(ctx, port.number, port.visibility) + if err != nil { + return fmt.Errorf("error updating port %d to %s: %w", port.number, port.visibility, err) + } + return nil + }) + + // wait for success or failure + err := g.Wait() a.StopProgressIndicator() if err != nil { - return fmt.Errorf("error update port to public: %w", err) + return err } + } return nil @@ -313,10 +364,7 @@ func (a *App) ForwardPorts(ctx context.Context, codespaceName string, ports []st codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName) if err != nil { - if err == errNoCodespaces { - return err - } - return fmt.Errorf("error getting codespace: %w", err) + return err } session, err := codespaces.ConnectToLiveshare(ctx, a, noopLogger(), a.apiClient, codespace) diff --git a/pkg/cmd/codespace/ports_test.go b/pkg/cmd/codespace/ports_test.go new file mode 100644 index 000000000..0154ff7d8 --- /dev/null +++ b/pkg/cmd/codespace/ports_test.go @@ -0,0 +1,270 @@ +package codespace + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/liveshare" + livesharetest "github.com/cli/cli/v2/pkg/liveshare/test" + "github.com/sourcegraph/jsonrpc2" +) + +func TestPortsUpdateVisibilitySuccess(t *testing.T) { + portVisibilities := []portVisibility{ + { + number: 80, + visibility: "org", + }, + { + number: 9999, + visibility: "public", + }, + } + + eventResponses := []string{ + "serverSharing.sharingSucceeded", + "serverSharing.sharingSucceeded", + } + + portsData := []liveshare.PortNotification{ + { + Success: true, + Port: 80, + ChangeKind: liveshare.PortChangeKindUpdate, + }, + { + Success: true, + Port: 9999, + ChangeKind: liveshare.PortChangeKindUpdate, + }, + } + + err := runUpdateVisibilityTest(t, portVisibilities, eventResponses, portsData) + + if err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestPortsUpdateVisibilityFailure403(t *testing.T) { + portVisibilities := []portVisibility{ + { + number: 80, + visibility: "org", + }, + { + number: 9999, + visibility: "public", + }, + } + + eventResponses := []string{ + "serverSharing.sharingSucceeded", + "serverSharing.sharingFailed", + } + + portsData := []liveshare.PortNotification{ + { + Success: true, + Port: 80, + ChangeKind: liveshare.PortChangeKindUpdate, + }, + { + Success: false, + Port: 9999, + ChangeKind: liveshare.PortChangeKindUpdate, + ErrorDetail: "test error", + StatusCode: 403, + }, + } + + err := runUpdateVisibilityTest(t, portVisibilities, eventResponses, portsData) + if err == nil { + t.Fatalf("runUpdateVisibilityTest succeeded unexpectedly") + } + + if errors.Unwrap(err) != errUpdatePortVisibilityForbidden { + t.Errorf("expected: %v, got: %v", errUpdatePortVisibilityForbidden, errors.Unwrap(err)) + } +} + +func TestPortsUpdateVisibilityFailure(t *testing.T) { + portVisibilities := []portVisibility{ + { + number: 80, + visibility: "org", + }, + { + number: 9999, + visibility: "public", + }, + } + + eventResponses := []string{ + "serverSharing.sharingSucceeded", + "serverSharing.sharingFailed", + } + + portsData := []liveshare.PortNotification{ + { + Success: true, + Port: 80, + ChangeKind: liveshare.PortChangeKindUpdate, + }, + { + Success: false, + Port: 9999, + ChangeKind: liveshare.PortChangeKindUpdate, + ErrorDetail: "test error", + }, + } + + err := runUpdateVisibilityTest(t, portVisibilities, eventResponses, portsData) + if err == nil { + t.Fatalf("runUpdateVisibilityTest succeeded unexpectedly") + } + + var expectedErr *ErrUpdatingPortVisibility + if !errors.As(err, &expectedErr) { + t.Errorf("expected: %v, got: %v", expectedErr, err) + } +} + +type joinWorkspaceResult struct { + SessionNumber int `json:"sessionNumber"` +} + +func runUpdateVisibilityTest(t *testing.T, portVisibilities []portVisibility, eventResponses []string, portsData []liveshare.PortNotification) error { + t.Helper() + + joinWorkspace := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) { + return joinWorkspaceResult{1}, nil + } + const sessionToken = "session-token" + + ch := make(chan *jsonrpc2.Conn, 1) + updateSharedVisibility := func(conn *jsonrpc2.Conn, rpcReq *jsonrpc2.Request) (interface{}, error) { + ch <- conn + return nil, nil + } + testServer, err := livesharetest.NewServer( + livesharetest.WithNonSecure(), + livesharetest.WithPassword(sessionToken), + livesharetest.WithService("workspace.joinWorkspace", joinWorkspace), + livesharetest.WithService("serverSharing.updateSharedServerPrivacy", updateSharedVisibility), + ) + if err != nil { + return fmt.Errorf("unable to create test server: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + for i, pd := range portsData { + select { + case <-ctx.Done(): + return + case conn := <-ch: + _, _ = conn.DispatchCall(ctx, eventResponses[i], pd, nil) + } + } + }() + + mockApi := &apiClientMock{ + GetCodespaceFunc: func(ctx context.Context, codespaceName string, includeConnection bool) (*api.Codespace, error) { + return &api.Codespace{ + Name: "codespace-name", + State: api.CodespaceStateAvailable, + Connection: api.CodespaceConnection{ + SessionID: "session-id", + SessionToken: sessionToken, + RelayEndpoint: testServer.URL(), + RelaySAS: "relay-sas", + HostPublicKeys: []string{livesharetest.SSHPublicKey}, + }, + }, nil + }, + } + + io, _, _, _ := iostreams.Test() + + a := &App{ + io: io, + apiClient: mockApi, + } + + var portArgs []string + for _, pv := range portVisibilities { + portArgs = append(portArgs, fmt.Sprintf("%d:%s", pv.number, pv.visibility)) + } + + return a.UpdatePortVisibility(ctx, "codespace-name", portArgs) +} + +func TestPendingOperationDisallowsListPorts(t *testing.T) { + app := testingPortsApp() + + if err := app.ListPorts(context.Background(), "disabledCodespace", nil); err != nil { + if err.Error() != "codespace is disabled while it has a pending operation: Some pending operation" { + t.Errorf("expected pending operation error, but got: %v", err) + } + } else { + t.Error("expected pending operation error, but got nothing") + } +} + +func TestPendingOperationDisallowsUpdatePortVisability(t *testing.T) { + app := testingPortsApp() + + if err := app.UpdatePortVisibility(context.Background(), "disabledCodespace", nil); err != nil { + if err.Error() != "codespace is disabled while it has a pending operation: Some pending operation" { + t.Errorf("expected pending operation error, but got: %v", err) + } + } else { + t.Error("expected pending operation error, but got nothing") + } +} + +func TestPendingOperationDisallowsForwardPorts(t *testing.T) { + app := testingPortsApp() + + if err := app.ForwardPorts(context.Background(), "disabledCodespace", nil); err != nil { + if err.Error() != "codespace is disabled while it has a pending operation: Some pending operation" { + t.Errorf("expected pending operation error, but got: %v", err) + } + } else { + t.Error("expected pending operation error, but got nothing") + } +} + +func testingPortsApp() *App { + user := &api.User{Login: "monalisa"} + disabledCodespace := &api.Codespace{ + Name: "disabledCodespace", + PendingOperation: true, + PendingOperationDisabledReason: "Some pending operation", + } + apiMock := &apiClientMock{ + GetCodespaceFunc: func(_ context.Context, name string, _ bool) (*api.Codespace, error) { + if name == "disabledCodespace" { + return disabledCodespace, nil + } + return nil, nil + }, + GetUserFunc: func(_ context.Context) (*api.User, error) { + return user, nil + }, + AuthorizedKeysFunc: func(_ context.Context, _ string) ([]byte, error) { + return []byte{}, nil + }, + } + + io, _, _, _ := iostreams.Test() + + return NewApp(io, nil, apiMock, nil) +} diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go index 726f2152f..69c9c9ee1 100644 --- a/pkg/cmd/codespace/ssh.go +++ b/pkg/cmd/codespace/ssh.go @@ -59,7 +59,7 @@ func newSSHCmd(app *App) *cobra.Command { $ gh codespace ssh $ gh codespace ssh --config > ~/.ssh/codespaces - $ echo 'include ~/.ssh/codespaces' >> ~/.ssh/config' + $ echo 'include ~/.ssh/codespaces' >> ~/.ssh/config `), PreRunE: func(c *cobra.Command, args []string) error { if opts.stdio { @@ -125,7 +125,7 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e codespace, err := getOrChooseCodespace(ctx, a.apiClient, opts.codespace) if err != nil { - return fmt.Errorf("get or choose codespace: %w", err) + return err } liveshareLogger := noopLogger() @@ -379,6 +379,7 @@ func newCpCmd(app *App) *cobra.Command { 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") + cpCmd.Flags().StringVarP(&opts.profile, "profile", "p", "", "Name of the SSH profile to use") return cpCmd } diff --git a/pkg/cmd/codespace/ssh_test.go b/pkg/cmd/codespace/ssh_test.go new file mode 100644 index 000000000..3b242ae4b --- /dev/null +++ b/pkg/cmd/codespace/ssh_test.go @@ -0,0 +1,47 @@ +package codespace + +import ( + "context" + "testing" + + "github.com/cli/cli/v2/internal/codespaces/api" + "github.com/cli/cli/v2/pkg/iostreams" +) + +func TestPendingOperationDisallowsSSH(t *testing.T) { + app := testingSSHApp() + + if err := app.SSH(context.Background(), []string{}, sshOptions{codespace: "disabledCodespace"}); err != nil { + if err.Error() != "codespace is disabled while it has a pending operation: Some pending operation" { + t.Errorf("expected pending operation error, but got: %v", err) + } + } else { + t.Error("expected pending operation error, but got nothing") + } +} + +func testingSSHApp() *App { + user := &api.User{Login: "monalisa"} + disabledCodespace := &api.Codespace{ + Name: "disabledCodespace", + PendingOperation: true, + PendingOperationDisabledReason: "Some pending operation", + } + apiMock := &apiClientMock{ + GetCodespaceFunc: func(_ context.Context, name string, _ bool) (*api.Codespace, error) { + if name == "disabledCodespace" { + return disabledCodespace, nil + } + return nil, nil + }, + GetUserFunc: func(_ context.Context) (*api.User, error) { + return user, nil + }, + AuthorizedKeysFunc: func(_ context.Context, _ string) ([]byte, error) { + return []byte{}, nil + }, + } + + io, _, _, _ := iostreams.Test() + return NewApp(io, nil, apiMock, nil) +} diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go index 37602e1e0..f9ae516c9 100644 --- a/pkg/cmd/pr/checks/checks.go +++ b/pkg/cmd/pr/checks/checks.go @@ -134,7 +134,7 @@ func checksRun(opts *ChecksOptions) error { for { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, - Fields: []string{"number", "baseRefName", "statusCheckRollup"}, + Fields: []string{"number", "headRefName", "statusCheckRollup"}, } pr, _, err := opts.Finder.Find(findOptions) if err != nil { diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index e13d08b7a..f6d5fb37b 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -11,7 +11,6 @@ import ( "github.com/cli/cli/v2/context" "github.com/cli/cli/v2/git" "github.com/cli/cli/v2/internal/config" - "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmd/pr/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -322,18 +321,35 @@ func mergeRun(opts *MergeOptions) error { var branchToSwitchTo string if currentBranch == pr.HeadRefName { - branchToSwitchTo, err = api.RepoDefaultBranch(apiClient, baseRepo) + branchToSwitchTo = pr.BaseRefName + if branchToSwitchTo == "" { + branchToSwitchTo, err = api.RepoDefaultBranch(apiClient, baseRepo) + if err != nil { + return err + } + } + + remotes, err := opts.Remotes() if err != nil { return err } - err = git.CheckoutBranch(branchToSwitchTo) + baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()) if err != nil { return err } - err := pullLatestChanges(opts, baseRepo, branchToSwitchTo) - if err != nil { + if git.HasLocalBranch(branchToSwitchTo) { + if err := git.CheckoutBranch(branchToSwitchTo); err != nil { + return err + } + } else { + if err := git.CheckoutNewBranch(baseRemote.Name, branchToSwitchTo); err != nil { + return err + } + } + + if err := git.Pull(baseRemote.Name, branchToSwitchTo); err != nil { fmt.Fprintf(opts.IO.ErrOut, "%s warning: not possible to fast-forward to: %q\n", cs.WarningIcon(), branchToSwitchTo) } } @@ -364,25 +380,6 @@ func mergeRun(opts *MergeOptions) error { return nil } -func pullLatestChanges(opts *MergeOptions, repo ghrepo.Interface, branch string) error { - remotes, err := opts.Remotes() - if err != nil { - return err - } - - baseRemote, err := remotes.FindByRepo(repo.RepoOwner(), repo.RepoName()) - if err != nil { - return err - } - - err = git.Pull(baseRemote.Name, branch) - if err != nil { - return err - } - - return nil -} - func mergeMethodSurvey(baseRepo *api.Repository) (PullRequestMergeMethod, error) { type mergeOption struct { title string diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index d692b0a08..c7f30f7c4 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -480,6 +480,7 @@ func TestPrMerge_deleteBranch(t *testing.T) { cs, cmdTeardown := run.Stub() defer cmdTeardown(t) + cs.Register(`git rev-parse --verify refs/heads/master`, 0, "") cs.Register(`git checkout master`, 0, "") cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "") cs.Register(`git branch -D blueberries`, 0, "") @@ -497,6 +498,106 @@ func TestPrMerge_deleteBranch(t *testing.T) { `), output.Stderr()) } +func TestPrMerge_deleteBranch_nonDefault(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + shared.RunCommandFinder( + "", + &api.PullRequest{ + ID: "PR_10", + Number: 10, + State: "OPEN", + Title: "Blueberries are a good fruit", + HeadRefName: "blueberries", + MergeStateStatus: "CLEAN", + BaseRefName: "fruit", + }, + baseRepo("OWNER", "REPO", "master"), + ) + + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "PR_10", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + assert.NotContains(t, input, "commitHeadline") + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git rev-parse --verify refs/heads/fruit`, 0, "") + cs.Register(`git checkout fruit`, 0, "") + cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "") + cs.Register(`git branch -D blueberries`, 0, "") + cs.Register(`git pull --ff-only`, 0, "") + + output, err := runCommand(http, "blueberries", true, `pr merge --merge --delete-branch`) + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) + } + + assert.Equal(t, "", output.String()) + assert.Equal(t, heredoc.Doc(` + ✓ Merged pull request #10 (Blueberries are a good fruit) + ✓ Deleted branch blueberries and switched to branch fruit + `), output.Stderr()) +} + +func TestPrMerge_deleteBranch_checkoutNewBranch(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + shared.RunCommandFinder( + "", + &api.PullRequest{ + ID: "PR_10", + Number: 10, + State: "OPEN", + Title: "Blueberries are a good fruit", + HeadRefName: "blueberries", + MergeStateStatus: "CLEAN", + BaseRefName: "fruit", + }, + baseRepo("OWNER", "REPO", "master"), + ) + + http.Register( + httpmock.GraphQL(`mutation PullRequestMerge\b`), + httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { + assert.Equal(t, "PR_10", input["pullRequestId"].(string)) + assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + assert.NotContains(t, input, "commitHeadline") + })) + http.Register( + httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), + httpmock.StringResponse(`{}`)) + + cs, cmdTeardown := run.Stub() + defer cmdTeardown(t) + + cs.Register(`git rev-parse --verify refs/heads/fruit`, 1, "") + cs.Register(`git checkout -b fruit --track origin/fruit`, 0, "") + cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "") + cs.Register(`git branch -D blueberries`, 0, "") + cs.Register(`git pull --ff-only`, 0, "") + + output, err := runCommand(http, "blueberries", true, `pr merge --merge --delete-branch`) + if err != nil { + t.Fatalf("Got unexpected error running `pr merge` %s", err) + } + + assert.Equal(t, "", output.String()) + assert.Equal(t, heredoc.Doc(` + ✓ Merged pull request #10 (Blueberries are a good fruit) + ✓ Deleted branch blueberries and switched to branch fruit + `), output.Stderr()) +} + func TestPrMerge_deleteNonCurrentBranch(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) @@ -764,6 +865,7 @@ func TestPrMerge_alreadyMerged(t *testing.T) { cs, cmdTeardown := run.Stub() defer cmdTeardown(t) + cs.Register(`git rev-parse --verify refs/heads/master`, 0, "") cs.Register(`git checkout master`, 0, "") cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "") cs.Register(`git branch -D blueberries`, 0, "") @@ -906,6 +1008,7 @@ func TestPRMerge_interactiveWithDeleteBranch(t *testing.T) { cs, cmdTeardown := run.Stub() defer cmdTeardown(t) + cs.Register(`git rev-parse --verify refs/heads/master`, 0, "") cs.Register(`git checkout master`, 0, "") cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "") cs.Register(`git branch -D blueberries`, 0, "") diff --git a/pkg/cmd/repo/edit/edit.go b/pkg/cmd/repo/edit/edit.go index 86ccdaab1..1793caad2 100644 --- a/pkg/cmd/repo/edit/edit.go +++ b/pkg/cmd/repo/edit/edit.go @@ -8,44 +8,73 @@ import ( "io" "io/ioutil" "net/http" + "strings" + "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghinstance" "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/pkg/prompt" "github.com/cli/cli/v2/pkg/set" "github.com/spf13/cobra" "golang.org/x/sync/errgroup" ) +const ( + allowMergeCommits = "Allow Merge Commits" + allowSquashMerge = "Allow Squash Merging" + allowRebaseMerge = "Allow Rebase Merging" + + optionAllowForking = "Allow Forking" + optionDefaultBranchName = "Default Branch Name" + optionDescription = "Description" + optionHomePageURL = "Home Page URL" + optionIssues = "Issues" + optionMergeOptions = "Merge Options" + optionProjects = "Projects" + optionTemplateRepo = "Template Repository" + optionTopics = "Topics" + optionVisibility = "Visibility" + optionWikis = "Wikis" +) + type EditOptions struct { - HTTPClient *http.Client - Repository ghrepo.Interface - Edits EditRepositoryInput - AddTopics []string - RemoveTopics []string + HTTPClient *http.Client + Repository ghrepo.Interface + IO *iostreams.IOStreams + Edits EditRepositoryInput + AddTopics []string + RemoveTopics []string + InteractiveMode bool + // Cache of current repo topics to avoid retrieving them + // in multiple flows. + topicsCache []string } type EditRepositoryInput struct { - Description *string `json:"description,omitempty"` - Homepage *string `json:"homepage,omitempty"` - Visibility *string `json:"visibility,omitempty"` - EnableIssues *bool `json:"has_issues,omitempty"` - EnableProjects *bool `json:"has_projects,omitempty"` - EnableWiki *bool `json:"has_wiki,omitempty"` - IsTemplate *bool `json:"is_template,omitempty"` - DefaultBranch *string `json:"default_branch,omitempty"` - EnableSquashMerge *bool `json:"allow_squash_merge,omitempty"` - EnableMergeCommit *bool `json:"allow_merge_commit,omitempty"` - EnableRebaseMerge *bool `json:"allow_rebase_merge,omitempty"` - EnableAutoMerge *bool `json:"allow_auto_merge,omitempty"` - DeleteBranchOnMerge *bool `json:"delete_branch_on_merge,omitempty"` AllowForking *bool `json:"allow_forking,omitempty"` + DefaultBranch *string `json:"default_branch,omitempty"` + DeleteBranchOnMerge *bool `json:"delete_branch_on_merge,omitempty"` + Description *string `json:"description,omitempty"` + EnableAutoMerge *bool `json:"allow_auto_merge,omitempty"` + EnableIssues *bool `json:"has_issues,omitempty"` + EnableMergeCommit *bool `json:"allow_merge_commit,omitempty"` + EnableProjects *bool `json:"has_projects,omitempty"` + EnableRebaseMerge *bool `json:"allow_rebase_merge,omitempty"` + EnableSquashMerge *bool `json:"allow_squash_merge,omitempty"` + EnableWiki *bool `json:"has_wiki,omitempty"` + Homepage *string `json:"homepage,omitempty"` + IsTemplate *bool `json:"is_template,omitempty"` + Visibility *string `json:"visibility,omitempty"` } func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobra.Command { - opts := &EditOptions{} + opts := &EditOptions{ + IO: f.IOStreams, + } cmd := &cobra.Command{ Use: "edit []", @@ -71,10 +100,6 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobr gh repo edit --enable-projects=false `), RunE: func(cmd *cobra.Command, args []string) error { - if cmd.Flags().NFlag() == 0 { - return cmdutil.FlagErrorf("at least one flag is required") - } - if len(args) > 0 { var err error opts.Repository, err = ghrepo.FromFullName(args[0]) @@ -95,6 +120,14 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobr return err } + if cmd.Flags().NFlag() == 0 { + opts.InteractiveMode = true + } + + if opts.InteractiveMode && !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("specify properties to edit when not running interactively") + } + if runF != nil { return runF(opts) } @@ -124,6 +157,38 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(options *EditOptions) error) *cobr func editRun(ctx context.Context, opts *EditOptions) error { repo := opts.Repository + + if opts.InteractiveMode { + apiClient := api.NewClientFromHTTP(opts.HTTPClient) + fieldsToRetrieve := []string{ + "autoMergeAllowed", + "defaultBranchRef", + "deleteBranchOnMerge", + "description", + "hasIssuesEnabled", + "hasProjectsEnabled", + "hasWikiEnabled", + "homepageUrl", + "isInOrganization", + "isTemplate", + "mergeCommitAllowed", + "rebaseMergeAllowed", + "repositoryTopics", + "squashMergeAllowed", + "visibility", + } + opts.IO.StartProgressIndicator() + fetchedRepo, err := api.FetchRepository(apiClient, opts.Repository, fieldsToRetrieve) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + err = interactiveRepoEdit(opts, fetchedRepo) + if err != nil { + return err + } + } + apiPath := fmt.Sprintf("repos/%s/%s", repo.RepoOwner(), repo.RepoName()) body := &bytes.Buffer{} @@ -144,16 +209,19 @@ func editRun(ctx context.Context, opts *EditOptions) error { if len(opts.AddTopics) > 0 || len(opts.RemoveTopics) > 0 { g.Go(func() error { - existingTopics, err := getTopics(ctx, opts.HTTPClient, repo) - if err != nil { - return err + // opts.topicsCache gets populated in interactive mode + if !opts.InteractiveMode { + var err error + opts.topicsCache, err = getTopics(ctx, opts.HTTPClient, repo) + if err != nil { + return err + } } - oldTopics := set.NewStringSet() - oldTopics.AddValues(existingTopics) + oldTopics.AddValues(opts.topicsCache) newTopics := set.NewStringSet() - newTopics.AddValues(existingTopics) + newTopics.AddValues(opts.topicsCache) newTopics.AddValues(opts.AddTopics) newTopics.RemoveValues(opts.RemoveTopics) @@ -164,7 +232,209 @@ func editRun(ctx context.Context, opts *EditOptions) error { }) } - return g.Wait() + err := g.Wait() + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, + "%s Edited repository %s\n", + cs.SuccessIcon(), + ghrepo.FullName(repo)) + } + + return nil +} + +func interactiveChoice(r *api.Repository) ([]string, error) { + options := []string{ + optionDefaultBranchName, + optionDescription, + optionHomePageURL, + optionIssues, + optionMergeOptions, + optionProjects, + optionTemplateRepo, + optionTopics, + optionVisibility, + optionWikis, + } + if r.IsInOrganization { + options = append(options, optionAllowForking) + } + var answers []string + err := prompt.SurveyAskOne(&survey.MultiSelect{ + Message: "What do you want to edit?", + Options: options, + }, &answers, survey.WithPageSize(11)) + return answers, err +} + +func interactiveRepoEdit(opts *EditOptions, r *api.Repository) error { + for _, v := range r.RepositoryTopics.Nodes { + opts.topicsCache = append(opts.topicsCache, v.Topic.Name) + } + choices, err := interactiveChoice(r) + if err != nil { + return err + } + for _, c := range choices { + switch c { + case optionDescription: + opts.Edits.Description = &r.Description + err = prompt.SurveyAskOne(&survey.Input{ + Message: "Description of the repository", + Default: r.Description, + }, opts.Edits.Description) + if err != nil { + return err + } + case optionHomePageURL: + opts.Edits.Homepage = &r.HomepageURL + err = prompt.SurveyAskOne(&survey.Input{ + Message: "Repository home page URL", + Default: r.HomepageURL, + }, opts.Edits.Homepage) + if err != nil { + return err + } + case optionTopics: + var addTopics string + err = prompt.SurveyAskOne(&survey.Input{ + Message: "Add topics?(csv format)", + }, &addTopics) + if err != nil { + return err + } + if len(strings.TrimSpace(addTopics)) > 0 { + opts.AddTopics = strings.Split(addTopics, ",") + } + + if len(opts.topicsCache) > 0 { + err = prompt.SurveyAskOne(&survey.MultiSelect{ + Message: "Remove Topics", + Options: opts.topicsCache, + }, &opts.RemoveTopics) + if err != nil { + return err + } + } + case optionDefaultBranchName: + opts.Edits.DefaultBranch = &r.DefaultBranchRef.Name + err = prompt.SurveyAskOne(&survey.Input{ + Message: "Default branch name", + Default: r.DefaultBranchRef.Name, + }, opts.Edits.DefaultBranch) + if err != nil { + return err + } + case optionWikis: + opts.Edits.EnableWiki = &r.HasWikiEnabled + err = prompt.SurveyAskOne(&survey.Confirm{ + Message: "Enable Wikis?", + Default: r.HasWikiEnabled, + }, opts.Edits.EnableWiki) + if err != nil { + return err + } + case optionIssues: + opts.Edits.EnableIssues = &r.HasIssuesEnabled + err = prompt.SurveyAskOne(&survey.Confirm{ + Message: "Enable Issues?", + Default: r.HasIssuesEnabled, + }, opts.Edits.EnableIssues) + if err != nil { + return err + } + case optionProjects: + opts.Edits.EnableProjects = &r.HasProjectsEnabled + err = prompt.SurveyAskOne(&survey.Confirm{ + Message: "Enable Projects?", + Default: r.HasProjectsEnabled, + }, opts.Edits.EnableProjects) + if err != nil { + return err + } + case optionVisibility: + opts.Edits.Visibility = &r.Visibility + err = prompt.SurveyAskOne(&survey.Select{ + Message: "Visibility", + Options: []string{"public", "private", "internal"}, + Default: strings.ToLower(r.Visibility), + }, opts.Edits.Visibility) + if err != nil { + return err + } + case optionMergeOptions: + var defaultMergeOptions []string + var selectedMergeOptions []string + if r.MergeCommitAllowed { + defaultMergeOptions = append(defaultMergeOptions, allowMergeCommits) + } + if r.SquashMergeAllowed { + defaultMergeOptions = append(defaultMergeOptions, allowSquashMerge) + } + if r.RebaseMergeAllowed { + defaultMergeOptions = append(defaultMergeOptions, allowRebaseMerge) + } + err = prompt.SurveyAskOne(&survey.MultiSelect{ + Message: "Allowed merge strategies", + Default: defaultMergeOptions, + Options: []string{allowMergeCommits, allowSquashMerge, allowRebaseMerge}, + }, &selectedMergeOptions) + if err != nil { + return err + } + enableMergeCommit := isIncluded(allowMergeCommits, selectedMergeOptions) + opts.Edits.EnableMergeCommit = &enableMergeCommit + enableSquashMerge := isIncluded(allowSquashMerge, selectedMergeOptions) + opts.Edits.EnableSquashMerge = &enableSquashMerge + enableRebaseMerge := isIncluded(allowRebaseMerge, selectedMergeOptions) + opts.Edits.EnableRebaseMerge = &enableRebaseMerge + if !enableMergeCommit && !enableSquashMerge && !enableRebaseMerge { + return fmt.Errorf("you need to allow at least one merge strategy") + } + + opts.Edits.EnableAutoMerge = &r.AutoMergeAllowed + err = prompt.SurveyAskOne(&survey.Confirm{ + Message: "Enable Auto Merge?", + Default: r.AutoMergeAllowed, + }, opts.Edits.EnableAutoMerge) + if err != nil { + return err + } + + opts.Edits.DeleteBranchOnMerge = &r.DeleteBranchOnMerge + err = prompt.SurveyAskOne(&survey.Confirm{ + Message: "Automatically delete head branches after merging?", + Default: r.DeleteBranchOnMerge, + }, opts.Edits.DeleteBranchOnMerge) + if err != nil { + return err + } + case optionTemplateRepo: + opts.Edits.IsTemplate = &r.IsTemplate + err = prompt.SurveyAskOne(&survey.Confirm{ + Message: "Convert into a template repository?", + Default: r.IsTemplate, + }, opts.Edits.IsTemplate) + if err != nil { + return err + } + case optionAllowForking: + opts.Edits.AllowForking = &r.ForkingAllowed + err = prompt.SurveyAskOne(&survey.Confirm{ + Message: "Allow forking (of an organization repository)?", + Default: r.ForkingAllowed, + }, opts.Edits.AllowForking) + if err != nil { + return err + } + } + } + return nil } func getTopics(ctx context.Context, httpClient *http.Client, repo ghrepo.Interface) ([]string, error) { @@ -228,3 +498,12 @@ func setTopics(ctx context.Context, httpClient *http.Client, repo ghrepo.Interfa return nil } + +func isIncluded(value string, opts []string) bool { + for _, opt := range opts { + if strings.EqualFold(opt, value) { + return true + } + } + return false +} diff --git a/pkg/cmd/repo/edit/edit_test.go b/pkg/cmd/repo/edit/edit_test.go index 9130fbd64..1cf8fd4dd 100644 --- a/pkg/cmd/repo/edit/edit_test.go +++ b/pkg/cmd/repo/edit/edit_test.go @@ -7,6 +7,8 @@ import ( "net/http" "testing" + "github.com/cli/cli/v2/pkg/prompt" + "github.com/cli/cli/v2/internal/ghrepo" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/httpmock" @@ -23,11 +25,6 @@ func TestNewCmdEdit(t *testing.T) { wantOpts EditOptions wantErr string }{ - { - name: "no argument", - args: "", - wantErr: "at least one flag is required", - }, { name: "change repo description", args: "--description hello", @@ -135,6 +132,11 @@ func Test_editRun(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + io.SetStderrTTY(true) + httpReg := &httpmock.Registry{} defer httpReg.Verify(t) if tt.httpStubs != nil { @@ -143,6 +145,186 @@ func Test_editRun(t *testing.T) { opts := &tt.opts opts.HTTPClient = &http.Client{Transport: httpReg} + opts.IO = io + + err := editRun(context.Background(), opts) + if tt.wantsErr == "" { + require.NoError(t, err) + } else { + assert.EqualError(t, err, tt.wantsErr) + return + } + }) + } +} + +func Test_editRun_interactive(t *testing.T) { + tests := []struct { + name string + opts EditOptions + askStubs func(*prompt.AskStubber) + httpStubs func(*testing.T, *httpmock.Registry) + wantsStderr string + wantsErr string + }{ + { + name: "updates repo description", + opts: EditOptions{ + Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), + InteractiveMode: true, + }, + askStubs: func(as *prompt.AskStubber) { + as.StubPrompt("What do you want to edit?").AnswerWith([]string{"Description"}) + as.StubPrompt("Description of the repository").AnswerWith("awesome repo description") + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { + "data": { + "repository": { + "description": "old description", + "homePageUrl": "https://url.com", + "defaultBranchRef": { + "name": "main" + }, + "isInOrganization": false, + "repositoryTopics": { + "nodes": [{ + "topic": { + "name": "x" + } + }] + } + } + } + }`)) + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO"), + httpmock.RESTPayload(200, `{}`, func(payload map[string]interface{}) { + assert.Equal(t, "awesome repo description", payload["description"]) + })) + }, + }, + { + name: "updates repo topics", + opts: EditOptions{ + Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), + InteractiveMode: true, + }, + askStubs: func(as *prompt.AskStubber) { + as.StubPrompt("What do you want to edit?").AnswerWith([]string{"Description", "Topics"}) + as.StubPrompt("Description of the repository").AnswerWith("awesome repo description") + as.StubPrompt("Add topics?(csv format)").AnswerWith("a,b,c,d") + as.StubPrompt("Remove Topics").AnswerWith([]string{"x"}) + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { + "data": { + "repository": { + "description": "old description", + "homePageUrl": "https://url.com", + "defaultBranchRef": { + "name": "main" + }, + "isInOrganization": false, + "repositoryTopics": { + "nodes": [{ + "topic": { + "name": "x" + } + }] + } + } + } + }`)) + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO"), + httpmock.RESTPayload(200, `{}`, func(payload map[string]interface{}) { + assert.Equal(t, "awesome repo description", payload["description"]) + })) + reg.Register( + httpmock.REST("PUT", "repos/OWNER/REPO/topics"), + httpmock.RESTPayload(200, `{}`, func(payload map[string]interface{}) { + assert.Equal(t, []interface{}{"a", "b", "c", "d"}, payload["names"]) + })) + }, + }, + { + name: "updates repo merge options", + opts: EditOptions{ + Repository: ghrepo.NewWithHost("OWNER", "REPO", "github.com"), + InteractiveMode: true, + }, + askStubs: func(as *prompt.AskStubber) { + as.StubPrompt("What do you want to edit?").AnswerWith([]string{"Merge Options"}) + as.StubPrompt("Allowed merge strategies").AnswerWith([]string{allowMergeCommits, allowRebaseMerge}) + as.StubPrompt("Enable Auto Merge?").AnswerWith(false) + as.StubPrompt("Automatically delete head branches after merging?").AnswerWith(false) + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { + "data": { + "repository": { + "description": "old description", + "homePageUrl": "https://url.com", + "defaultBranchRef": { + "name": "main" + }, + "isInOrganization": false, + "squashMergeAllowed": false, + "rebaseMergeAllowed": true, + "mergeCommitAllowed": true, + "deleteBranchOnMerge": false, + "repositoryTopics": { + "nodes": [{ + "topic": { + "name": "x" + } + }] + } + } + } + }`)) + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO"), + httpmock.RESTPayload(200, `{}`, func(payload map[string]interface{}) { + assert.Equal(t, true, payload["allow_merge_commit"]) + assert.Equal(t, false, payload["allow_squash_merge"]) + assert.Equal(t, true, payload["allow_rebase_merge"]) + })) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + io.SetStderrTTY(true) + + httpReg := &httpmock.Registry{} + defer httpReg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(t, httpReg) + } + + opts := &tt.opts + opts.HTTPClient = &http.Client{Transport: httpReg} + opts.IO = io + + as := prompt.NewAskStubber(t) + if tt.askStubs != nil { + tt.askStubs(as) + } err := editRun(context.Background(), opts) if tt.wantsErr == "" { diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index 6ea865fe3..f77d17b55 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -25,6 +25,7 @@ import ( repoCmd "github.com/cli/cli/v2/pkg/cmd/repo" creditsCmd "github.com/cli/cli/v2/pkg/cmd/repo/credits" runCmd "github.com/cli/cli/v2/pkg/cmd/run" + searchCmd "github.com/cli/cli/v2/pkg/cmd/search" secretCmd "github.com/cli/cli/v2/pkg/cmd/secret" sshKeyCmd "github.com/cli/cli/v2/pkg/cmd/ssh-key" versionCmd "github.com/cli/cli/v2/pkg/cmd/version" @@ -79,6 +80,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmd.AddCommand(gpgKeyCmd.NewCmdGPGKey(f)) cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams)) cmd.AddCommand(extensionCmd.NewCmdExtension(f)) + cmd.AddCommand(searchCmd.NewCmdSearch(f)) cmd.AddCommand(secretCmd.NewCmdSecret(f)) cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f)) cmd.AddCommand(newCodespaceCmd(f)) diff --git a/pkg/cmd/run/list/list_test.go b/pkg/cmd/run/list/list_test.go index fb18d04d2..a3d2b06de 100644 --- a/pkg/cmd/run/list/list_test.go +++ b/pkg/cmd/run/list/list_test.go @@ -108,24 +108,6 @@ func TestNewCmdList(t *testing.T) { } func TestListRun(t *testing.T) { - // helper to match mocked requests by their query params along with method and path - queryMatcher := func(method string, path string, query url.Values) httpmock.Matcher { - return func(req *http.Request) bool { - if !httpmock.REST(method, path)(req) { - return false - } - - actualQuery := req.URL.Query() - - for param := range query { - if !(actualQuery.Get(param) == query.Get(param)) { - return false - } - } - - return true - } - } tests := []struct { name string opts *ListOptions @@ -244,7 +226,7 @@ func TestListRun(t *testing.T) { }, stubs: func(reg *httpmock.Registry) { reg.Register( - queryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{ + httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{ "branch": []string{"the-branch"}, }), httpmock.JSONResponse(shared.RunsPayload{}), @@ -261,7 +243,7 @@ func TestListRun(t *testing.T) { }, stubs: func(reg *httpmock.Registry) { reg.Register( - queryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{ + httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{ "actor": []string{"bak1an"}, }), httpmock.JSONResponse(shared.RunsPayload{}), diff --git a/pkg/cmd/run/rerun/rerun.go b/pkg/cmd/run/rerun/rerun.go index 9788a3061..45f231f90 100644 --- a/pkg/cmd/run/rerun/rerun.go +++ b/pkg/cmd/run/rerun/rerun.go @@ -18,7 +18,9 @@ type RerunOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) - RunID string + RunID string + OnlyFailed bool + JobID string Prompt bool } @@ -37,12 +39,18 @@ func NewCmdRerun(f *cmdutil.Factory, runF func(*RerunOptions) error) *cobra.Comm // support `-R, --repo` override opts.BaseRepo = f.BaseRepo - if len(args) > 0 { + if len(args) == 0 && opts.JobID == "" { + if !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf("`` or `--job` required when not running interactively") + } else { + opts.Prompt = true + } + } else if len(args) > 0 { opts.RunID = args[0] - } else if !opts.IO.CanPrompt() { - return cmdutil.FlagErrorf("run ID required when not running interactively") - } else { - opts.Prompt = true + } + + if opts.RunID != "" && opts.JobID != "" { + return cmdutil.FlagErrorf("specify only one of `` or `--job`") } if runF != nil { @@ -52,6 +60,9 @@ func NewCmdRerun(f *cmdutil.Factory, runF func(*RerunOptions) error) *cobra.Comm }, } + cmd.Flags().BoolVar(&opts.OnlyFailed, "failed", false, "Rerun only failed jobs, including dependencies") + cmd.Flags().StringVarP(&opts.JobID, "job", "j", "", "Rerun a specific job from a run, including dependencies") + return cmd } @@ -67,10 +78,23 @@ func runRerun(opts *RerunOptions) error { return fmt.Errorf("failed to determine base repo: %w", err) } + cs := opts.IO.ColorScheme() + runID := opts.RunID + jobID := opts.JobID + var selectedJob *shared.Job + + if jobID != "" { + opts.IO.StartProgressIndicator() + selectedJob, err = shared.GetJob(client, repo, jobID) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("failed to get job: %w", err) + } + runID = fmt.Sprintf("%d", selectedJob.RunID) + } if opts.Prompt { - cs := opts.IO.ColorScheme() runs, err := shared.GetRunsWithFilter(client, repo, nil, 10, func(run shared.Run) bool { if run.Status != shared.Completed { return false @@ -83,7 +107,7 @@ func runRerun(opts *RerunOptions) error { return fmt.Errorf("failed to get runs: %w", err) } if len(runs) == 0 { - return errors.New("no recent runs have failed; please specify a specific run ID") + return errors.New("no recent runs have failed; please specify a specific ``") } runID, err = shared.PromptForRun(cs, runs) if err != nil { @@ -91,30 +115,73 @@ func runRerun(opts *RerunOptions) error { } } - opts.IO.StartProgressIndicator() - run, err := shared.GetRun(client, repo, runID) - opts.IO.StopProgressIndicator() - if err != nil { - return fmt.Errorf("failed to get run: %w", err) - } - - path := fmt.Sprintf("repos/%s/actions/runs/%d/rerun", ghrepo.FullName(repo), run.ID) - - err = client.REST(repo.RepoHost(), "POST", path, nil, nil) - if err != nil { - var httpError api.HTTPError - if errors.As(err, &httpError) && httpError.StatusCode == 403 { - return fmt.Errorf("run %d cannot be rerun; its workflow file may be broken.", run.ID) + if opts.JobID != "" { + err = rerunJob(client, repo, selectedJob) + if err != nil { + return err + } + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Requested rerun of job %s on run %s\n", + cs.SuccessIcon(), + cs.Cyanf("%d", selectedJob.ID), + cs.Cyanf("%d", selectedJob.RunID)) + } + } else { + opts.IO.StartProgressIndicator() + run, err := shared.GetRun(client, repo, runID) + opts.IO.StopProgressIndicator() + if err != nil { + return fmt.Errorf("failed to get run: %w", err) } - return fmt.Errorf("failed to rerun: %w", err) - } - if opts.IO.CanPrompt() { - cs := opts.IO.ColorScheme() - fmt.Fprintf(opts.IO.Out, "%s Requested rerun of run %s\n", - cs.SuccessIcon(), - cs.Cyanf("%d", run.ID)) + err = rerunRun(client, repo, run, opts.OnlyFailed) + if err != nil { + return err + } + if opts.IO.IsStdoutTTY() { + onlyFailedMsg := "" + if opts.OnlyFailed { + onlyFailedMsg = "(failed jobs) " + } + fmt.Fprintf(opts.IO.Out, "%s Requested rerun %sof run %s\n", + cs.SuccessIcon(), + onlyFailedMsg, + cs.Cyanf("%d", run.ID)) + } } return nil } + +func rerunRun(client *api.Client, repo ghrepo.Interface, run *shared.Run, onlyFailed bool) error { + runVerb := "rerun" + if onlyFailed { + runVerb = "rerun-failed-jobs" + } + + path := fmt.Sprintf("repos/%s/actions/runs/%d/%s", ghrepo.FullName(repo), run.ID, runVerb) + + err := client.REST(repo.RepoHost(), "POST", path, nil, nil) + if err != nil { + var httpError api.HTTPError + if errors.As(err, &httpError) && httpError.StatusCode == 403 { + return fmt.Errorf("run %d cannot be rerun; its workflow file may be broken", run.ID) + } + return fmt.Errorf("failed to rerun: %w", err) + } + return nil +} + +func rerunJob(client *api.Client, repo ghrepo.Interface, job *shared.Job) error { + path := fmt.Sprintf("repos/%s/actions/jobs/%d/rerun", ghrepo.FullName(repo), job.ID) + + err := client.REST(repo.RepoHost(), "POST", path, nil, nil) + if err != nil { + var httpError api.HTTPError + if errors.As(err, &httpError) && httpError.StatusCode == 403 { + return fmt.Errorf("job %d cannot be rerun", job.ID) + } + return fmt.Errorf("failed to rerun: %w", err) + } + return nil +} diff --git a/pkg/cmd/run/rerun/rerun_test.go b/pkg/cmd/run/rerun/rerun_test.go index 11362ce4b..0f7c80997 100644 --- a/pkg/cmd/run/rerun/rerun_test.go +++ b/pkg/cmd/run/rerun/rerun_test.go @@ -50,6 +50,48 @@ func TestNewCmdRerun(t *testing.T) { RunID: "1234", }, }, + { + name: "failed arg nontty", + cli: "4321 --failed", + wants: RerunOptions{ + RunID: "4321", + OnlyFailed: true, + }, + }, + { + name: "failed arg", + tty: true, + cli: "--failed", + wants: RerunOptions{ + Prompt: true, + OnlyFailed: true, + }, + }, + { + name: "with arg job", + tty: true, + cli: "--job 1234", + wants: RerunOptions{ + JobID: "1234", + }, + }, + { + name: "with args jobID and runID fails", + tty: true, + cli: "1234 --job 5678", + wantsErr: true, + }, + { + name: "with arg job with no ID fails", + tty: true, + cli: "--job", + wantsErr: true, + }, + { + name: "with arg job with no ID no tty fails", + cli: "--job", + wantsErr: true, + }, } for _, tt := range tests { @@ -117,6 +159,39 @@ func TestRerun(t *testing.T) { }, wantOut: "✓ Requested rerun of run 1234\n", }, + { + name: "arg including onlyFailed", + tty: true, + opts: &RerunOptions{ + RunID: "1234", + OnlyFailed: true, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"), + httpmock.JSONResponse(shared.FailedRun)) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/runs/1234/rerun-failed-jobs"), + httpmock.StringResponse("{}")) + }, + wantOut: "✓ Requested rerun (failed jobs) of run 1234\n", + }, + { + name: "arg including a specific job", + tty: true, + opts: &RerunOptions{ + JobID: "20", // 20 is shared.FailedJob.ID + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/actions/jobs/20"), + httpmock.JSONResponse(shared.FailedJob)) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO/actions/jobs/20/rerun"), + httpmock.StringResponse("{}")) + }, + wantOut: "✓ Requested rerun of job 20 on run 1234\n", + }, { name: "prompt", tty: true, @@ -158,7 +233,7 @@ func TestRerun(t *testing.T) { }})) }, wantErr: true, - errOut: "no recent runs have failed; please specify a specific run ID", + errOut: "no recent runs have failed; please specify a specific ``", }, { name: "unrerunnable", @@ -175,7 +250,7 @@ func TestRerun(t *testing.T) { httpmock.StatusStringResponse(403, "no")) }, wantErr: true, - errOut: "run 3 cannot be rerun; its workflow file may be broken.", + errOut: "run 3 cannot be rerun; its workflow file may be broken", }, } diff --git a/pkg/cmd/run/shared/shared.go b/pkg/cmd/run/shared/shared.go index b015ecc0e..81cbcc8f4 100644 --- a/pkg/cmd/run/shared/shared.go +++ b/pkg/cmd/run/shared/shared.go @@ -307,6 +307,18 @@ func GetJobs(client *api.Client, repo ghrepo.Interface, run Run) ([]Job, error) return result.Jobs, nil } +func GetJob(client *api.Client, repo ghrepo.Interface, jobID string) (*Job, error) { + path := fmt.Sprintf("repos/%s/actions/jobs/%s", ghrepo.FullName(repo), jobID) + + var result Job + err := client.REST(repo.RepoHost(), "GET", path, nil, &result) + if err != nil { + return nil, err + } + + return &result, nil +} + func PromptForRun(cs *iostreams.ColorScheme, runs []Run) (string, error) { var selected int now := time.Now() diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go index f41f37801..63518a632 100644 --- a/pkg/cmd/run/view/view.go +++ b/pkg/cmd/run/view/view.go @@ -183,7 +183,7 @@ func runView(opts *ViewOptions) error { if jobID != "" { opts.IO.StartProgressIndicator() - selectedJob, err = getJob(client, repo, jobID) + selectedJob, err = shared.GetJob(client, repo, jobID) opts.IO.StopProgressIndicator() if err != nil { return fmt.Errorf("failed to get job: %w", err) @@ -395,18 +395,6 @@ func runView(opts *ViewOptions) error { return nil } -func getJob(client *api.Client, repo ghrepo.Interface, jobID string) (*shared.Job, error) { - path := fmt.Sprintf("repos/%s/actions/jobs/%s", ghrepo.FullName(repo), jobID) - - var result shared.Job - err := client.REST(repo.RepoHost(), "GET", path, nil, &result) - if err != nil { - return nil, err - } - - return &result, nil -} - func getLog(httpClient *http.Client, logURL string) (io.ReadCloser, error) { req, err := http.NewRequest("GET", logURL, nil) if err != nil { diff --git a/pkg/cmd/search/repos/repos.go b/pkg/cmd/search/repos/repos.go new file mode 100644 index 000000000..878599dc6 --- /dev/null +++ b/pkg/cmd/search/repos/repos.go @@ -0,0 +1,203 @@ +package repos + +import ( + "fmt" + "strings" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/search" + "github.com/cli/cli/v2/pkg/text" + "github.com/cli/cli/v2/utils" + "github.com/spf13/cobra" +) + +const ( + // Limitation of GitHub search see: + // https://docs.github.com/en/rest/reference/search + searchMaxResults = 1000 +) + +type ReposOptions struct { + Browser cmdutil.Browser + Exporter cmdutil.Exporter + IO *iostreams.IOStreams + Query search.Query + Searcher search.Searcher + WebMode bool +} + +func NewCmdRepos(f *cmdutil.Factory, runF func(*ReposOptions) error) *cobra.Command { + var order string + var sort string + opts := &ReposOptions{ + Browser: f.Browser, + IO: f.IOStreams, + Query: search.Query{Kind: search.KindRepositories}, + } + + cmd := &cobra.Command{ + Use: "repos []", + Short: "Search for repositories", + Long: heredoc.Doc(` + Search for repositories on GitHub. + + The command supports constructing queries using the GitHub search syntax, + using the parameter and qualifier flags, or a combination of the two. + + GitHub search syntax is documented at: + https://docs.github.com/search-github/searching-on-github/searching-for-repositories + `), + Example: heredoc.Doc(` + # search repositories matching set of keywords "cli" and "shell" + $ gh search repos cli shell + + # search repositories matching phrase "vim plugin" + $ gh search repos "vim plugin" + + # search repositories public repos in the microsoft organization + $ gh search repos --owner=microsoft --visibility=public + + # search repositories with a set of topics + $ gh search repos --topic=unix,terminal + + # search repositories by coding language and number of good first issues + $ gh search repos --language=go --good-first-issues=">=10" + `), + RunE: func(c *cobra.Command, args []string) error { + if len(args) == 0 && c.Flags().NFlag() == 0 { + return cmdutil.FlagErrorf("specify search keywords or flags") + } + if opts.Query.Limit < 1 || opts.Query.Limit > searchMaxResults { + return cmdutil.FlagErrorf("`--limit` must be between 1 and 1000") + } + if c.Flags().Changed("order") { + opts.Query.Order = order + } + if c.Flags().Changed("sort") { + opts.Query.Sort = sort + } + opts.Query.Keywords = args + if runF != nil { + return runF(opts) + } + var err error + opts.Searcher, err = searcher(f) + if err != nil { + return err + } + return reposRun(opts) + }, + } + + // Output flags + cmdutil.AddJSONFlags(cmd, &opts.Exporter, search.RepositoryFields) + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the search query in the web browser") + + // Query parameter flags + cmd.Flags().IntVarP(&opts.Query.Limit, "limit", "L", 30, "Maximum number of repositories to fetch") + cmdutil.StringEnumFlag(cmd, &order, "order", "", "desc", []string{"asc", "desc"}, "Order of repositories returned, ignored unless '--sort' flag is specified") + cmdutil.StringEnumFlag(cmd, &sort, "sort", "", "best-match", []string{"forks", "help-wanted-issues", "stars", "updated"}, "Sort fetched repositories") + + // Query qualifier flags + cmdutil.NilBoolFlag(cmd, &opts.Query.Qualifiers.Archived, "archived", "", "Filter based on archive state") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Created, "created", "", "Filter based on created at `date`") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Followers, "followers", "", "Filter based on `number` of followers") + cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.Fork, "include-forks", "", "", []string{"false", "true", "only"}, "Include forks in fetched repositories") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Forks, "forks", "", "Filter on `number` of forks") + cmd.Flags().StringVar(&opts.Query.Qualifiers.GoodFirstIssues, "good-first-issues", "", "Filter on `number` of issues with the 'good first issue' label") + cmd.Flags().StringVar(&opts.Query.Qualifiers.HelpWantedIssues, "help-wanted-issues", "", "Filter on `number` of issues with the 'help wanted' label") + cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.In, "match", "", nil, []string{"name", "description", "readme"}, "Restrict search to specific field of repository") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Language, "language", "", "Filter based on the coding language") + cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.License, "license", nil, "Filter based on license type") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Org, "owner", "", "Filter on owner") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Pushed, "updated", "", "Filter on last updated at `date`") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Size, "size", "", "Filter on a size range, in kilobytes") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Stars, "stars", "", "Filter on `number` of stars") + cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Topic, "topic", nil, "Filter on topic") + cmd.Flags().StringVar(&opts.Query.Qualifiers.Topics, "number-topics", "", "Filter on `number` of topics") + cmdutil.StringEnumFlag(cmd, &opts.Query.Qualifiers.Is, "visibility", "", "", []string{"public", "private", "internal"}, "Filter based on visibility") + + return cmd +} + +func reposRun(opts *ReposOptions) error { + io := opts.IO + if opts.WebMode { + url := opts.Searcher.URL(opts.Query) + if io.IsStdoutTTY() { + fmt.Fprintf(io.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(url)) + } + return opts.Browser.Browse(url) + } + io.StartProgressIndicator() + result, err := opts.Searcher.Repositories(opts.Query) + io.StopProgressIndicator() + if err != nil { + return err + } + if err := io.StartPager(); err == nil { + defer io.StopPager() + } else { + fmt.Fprintf(io.ErrOut, "failed to start pager: %v\n", err) + } + if opts.Exporter != nil { + return opts.Exporter.Write(io, result.Items) + } + return displayResults(io, result) +} + +func displayResults(io *iostreams.IOStreams, results search.RepositoriesResult) error { + cs := io.ColorScheme() + tp := utils.NewTablePrinter(io) + for _, repo := range results.Items { + tags := []string{repo.Visibility} + if repo.IsFork { + tags = append(tags, "fork") + } + if repo.IsArchived { + tags = append(tags, "archived") + } + info := strings.Join(tags, ", ") + infoColor := cs.Gray + if repo.IsPrivate { + infoColor = cs.Yellow + } + tp.AddField(repo.FullName, nil, cs.Bold) + description := repo.Description + tp.AddField(text.ReplaceExcessiveWhitespace(description), nil, nil) + tp.AddField(info, nil, infoColor) + if tp.IsTTY() { + tp.AddField(utils.FuzzyAgoAbbr(time.Now(), repo.UpdatedAt), nil, cs.Gray) + } else { + tp.AddField(repo.UpdatedAt.Format(time.RFC3339), nil, nil) + } + tp.EndRow() + } + if io.IsStdoutTTY() { + header := "No repositories matched your search\n" + if len(results.Items) > 0 { + header = fmt.Sprintf("Showing %d of %d repositories\n\n", len(results.Items), results.Total) + } + fmt.Fprintf(io.Out, "\n%s", header) + } + return tp.Render() +} + +func searcher(f *cmdutil.Factory) (search.Searcher, error) { + cfg, err := f.Config() + if err != nil { + return nil, err + } + host, err := cfg.DefaultHost() + if err != nil { + return nil, err + } + client, err := f.HttpClient() + if err != nil { + return nil, err + } + return search.NewSearcher(client, host), nil +} diff --git a/pkg/cmd/search/repos/repos_test.go b/pkg/cmd/search/repos/repos_test.go new file mode 100644 index 000000000..872171b01 --- /dev/null +++ b/pkg/cmd/search/repos/repos_test.go @@ -0,0 +1,295 @@ +package repos + +import ( + "bytes" + "fmt" + "testing" + "time" + + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/pkg/search" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdRepos(t *testing.T) { + var trueBool = true + tests := []struct { + name string + input string + output ReposOptions + wantErr bool + errMsg string + }{ + { + name: "no arguments", + input: "", + wantErr: true, + errMsg: "specify search keywords or flags", + }, + { + name: "keyword arguments", + input: "some search terms", + output: ReposOptions{ + Query: search.Query{Keywords: []string{"some", "search", "terms"}, Kind: "repositories", Limit: 30}, + }, + }, + { + name: "web flag", + input: "--web", + output: ReposOptions{ + Query: search.Query{Keywords: []string{}, Kind: "repositories", Limit: 30}, + WebMode: true, + }, + }, + { + name: "limit flag", + input: "--limit 10", + output: ReposOptions{Query: search.Query{Keywords: []string{}, Kind: "repositories", Limit: 10}}, + }, + { + name: "invalid limit flag", + input: "--limit 1001", + wantErr: true, + errMsg: "`--limit` must be between 1 and 1000", + }, + { + name: "order flag", + input: "--order asc", + output: ReposOptions{ + Query: search.Query{Keywords: []string{}, Kind: "repositories", Limit: 30, Order: "asc"}, + }, + }, + { + name: "invalid order flag", + input: "--order invalid", + wantErr: true, + errMsg: "invalid argument \"invalid\" for \"--order\" flag: valid values are {asc|desc}", + }, + { + name: "qualifier flags", + input: ` + --archived + --created=created + --followers=1 + --include-forks=true + --forks=2 + --good-first-issues=3 + --help-wanted-issues=4 + --match=description,readme + --language=language + --license=license + --owner=owner + --updated=updated + --size=5 + --stars=6 + --topic=topic + --number-topics=7 + --visibility=public + `, + output: ReposOptions{ + Query: search.Query{ + Keywords: []string{}, + Kind: "repositories", + Limit: 30, + Qualifiers: search.Qualifiers{ + Archived: &trueBool, + Created: "created", + Followers: "1", + Fork: "true", + Forks: "2", + GoodFirstIssues: "3", + HelpWantedIssues: "4", + In: []string{"description", "readme"}, + Language: "language", + License: []string{"license"}, + Org: "owner", + Pushed: "updated", + Size: "5", + Stars: "6", + Topic: []string{"topic"}, + Topics: "7", + Is: "public", + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + var gotOpts *ReposOptions + cmd := NewCmdRepos(f, func(opts *ReposOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.output.Query, gotOpts.Query) + assert.Equal(t, tt.output.WebMode, gotOpts.WebMode) + }) + } +} + +func Test_ReposRun(t *testing.T) { + var query = search.Query{ + Keywords: []string{"cli"}, + Kind: "repositories", + Limit: 30, + Qualifiers: search.Qualifiers{ + Stars: ">50", + Topic: []string{"golang"}, + }, + } + var updatedAt = time.Date(2021, 2, 28, 12, 30, 0, 0, time.UTC) + tests := []struct { + errMsg string + name string + opts *ReposOptions + tty bool + wantErr bool + wantStderr string + wantStdout string + }{ + { + name: "displays results tty", + opts: &ReposOptions{ + Query: query, + Searcher: &search.SearcherMock{ + RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) { + return search.RepositoriesResult{ + IncompleteResults: false, + Items: []search.Repository{ + {FullName: "test/cli", Description: "of course", IsPrivate: true, IsArchived: true, UpdatedAt: updatedAt, Visibility: "private"}, + {FullName: "test/cliing", Description: "wow", IsFork: true, UpdatedAt: updatedAt, Visibility: "public"}, + {FullName: "cli/cli", Description: "so much", IsArchived: false, UpdatedAt: updatedAt, Visibility: "internal"}, + }, + Total: 300, + }, nil + }, + }, + }, + tty: true, + wantStdout: "\nShowing 3 of 300 repositories\n\ntest/cli of course private, archived Feb 28, 2021\ntest/cliing wow public, fork Feb 28, 2021\ncli/cli so much internal Feb 28, 2021\n", + }, + { + name: "displays no results tty", + opts: &ReposOptions{ + Query: query, + Searcher: &search.SearcherMock{ + RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) { + return search.RepositoriesResult{}, nil + }, + }, + }, + tty: true, + wantStdout: "\nNo repositories matched your search\n", + }, + { + name: "displays results notty", + opts: &ReposOptions{ + Query: query, + Searcher: &search.SearcherMock{ + RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) { + return search.RepositoriesResult{ + IncompleteResults: false, + Items: []search.Repository{ + {FullName: "test/cli", Description: "of course", IsPrivate: true, IsArchived: true, UpdatedAt: updatedAt, Visibility: "private"}, + {FullName: "test/cliing", Description: "wow", IsFork: true, UpdatedAt: updatedAt, Visibility: "public"}, + {FullName: "cli/cli", Description: "so much", IsArchived: false, UpdatedAt: updatedAt, Visibility: "internal"}, + }, + Total: 300, + }, nil + }, + }, + }, + wantStdout: "test/cli\tof course\tprivate, archived\t2021-02-28T12:30:00Z\ntest/cliing\twow\tpublic, fork\t2021-02-28T12:30:00Z\ncli/cli\tso much\tinternal\t2021-02-28T12:30:00Z\n", + }, + { + name: "displays no results notty", + opts: &ReposOptions{ + Query: query, + Searcher: &search.SearcherMock{ + RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) { + return search.RepositoriesResult{}, nil + }, + }, + }, + }, + { + name: "displays search error", + opts: &ReposOptions{ + Query: query, + Searcher: &search.SearcherMock{ + RepositoriesFunc: func(query search.Query) (search.RepositoriesResult, error) { + return search.RepositoriesResult{}, fmt.Errorf("error with query") + }, + }, + }, + errMsg: "error with query", + wantErr: true, + }, + { + name: "opens browser for web mode tty", + opts: &ReposOptions{ + Browser: &cmdutil.TestBrowser{}, + Query: query, + Searcher: &search.SearcherMock{ + URLFunc: func(query search.Query) string { + return "https://github.com/search?type=repositories&q=cli" + }, + }, + WebMode: true, + }, + tty: true, + wantStderr: "Opening github.com/search in your browser.\n", + }, + { + name: "opens browser for web mode notty", + opts: &ReposOptions{ + Browser: &cmdutil.TestBrowser{}, + Query: query, + Searcher: &search.SearcherMock{ + URLFunc: func(query search.Query) string { + return "https://github.com/search?type=repositories&q=cli" + }, + }, + WebMode: true, + }, + }, + } + for _, tt := range tests { + io, _, stdout, stderr := iostreams.Test() + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) + io.SetStderrTTY(tt.tty) + tt.opts.IO = io + t.Run(tt.name, func(t *testing.T) { + err := reposRun(tt.opts) + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + return + } else if err != nil { + t.Fatalf("reposRun unexpected error: %v", err) + } + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/search/search.go b/pkg/cmd/search/search.go new file mode 100644 index 000000000..5342175c8 --- /dev/null +++ b/pkg/cmd/search/search.go @@ -0,0 +1,20 @@ +package search + +import ( + "github.com/cli/cli/v2/pkg/cmdutil" + "github.com/spf13/cobra" + + searchReposCmd "github.com/cli/cli/v2/pkg/cmd/search/repos" +) + +func NewCmdSearch(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "search ", + Short: "Search for repositories, issues, pull requests and users", + Long: "Search across all of GitHub.", + } + + cmd.AddCommand(searchReposCmd.NewCmdRepos(f, nil)) + + return cmd +} diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go index e197ad748..2ccc4e221 100644 --- a/pkg/cmd/secret/list/list.go +++ b/pkg/cmd/secret/list/list.go @@ -29,6 +29,7 @@ type ListOptions struct { OrgName string EnvName string UserSecrets bool + Application string } func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { @@ -43,9 +44,9 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman Short: "List secrets", Long: heredoc.Doc(` List secrets on one of the following levels: - - repository (default): available to Actions runs in a repository + - repository (default): available to Actions runs or Dependabot in a repository - environment: available to Actions runs for a deployment environment in a repository - - organization: available to Actions runs within an organization + - organization: available to Actions runs or Dependabot within an organization - user: available to Codespaces for your user `), Aliases: []string{"ls"}, @@ -69,6 +70,7 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization") cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "List secrets for an environment") cmd.Flags().BoolVarP(&opts.UserSecrets, "user", "u", false, "List a secret for your user") + cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Codespaces, shared.Dependabot}, "List secrets for a specific application") return cmd } @@ -90,15 +92,29 @@ func listRun(opts *ListOptions) error { } } + secretEntity, err := shared.GetSecretEntity(orgName, envName, opts.UserSecrets) + if err != nil { + return err + } + + secretApp, err := shared.GetSecretApp(opts.Application, secretEntity) + if err != nil { + return err + } + + if !shared.IsSupportedSecretEntity(secretApp, secretEntity) { + return fmt.Errorf("%s secrets are not supported for %s", secretEntity, secretApp) + } + var secrets []*Secret showSelectedRepoInfo := opts.IO.IsStdoutTTY() - if orgName == "" && !opts.UserSecrets { - if envName == "" { - secrets, err = getRepoSecrets(client, baseRepo) - } else { - secrets, err = getEnvSecrets(client, baseRepo, envName) - } - } else { + + switch secretEntity { + case shared.Repository: + secrets, err = getRepoSecrets(client, baseRepo, secretApp) + case shared.Environment: + secrets, err = getEnvSecrets(client, baseRepo, envName) + case shared.Organization, shared.User: var cfg config.Config var host string @@ -112,10 +128,10 @@ func listRun(opts *ListOptions) error { return err } - if opts.UserSecrets { + if secretEntity == shared.User { secrets, err = getUserSecrets(client, host, showSelectedRepoInfo) } else { - secrets, err = getOrgSecrets(client, host, orgName, showSelectedRepoInfo) + secrets, err = getOrgSecrets(client, host, orgName, showSelectedRepoInfo, secretApp) } } @@ -179,8 +195,8 @@ func fmtVisibility(s Secret) string { return "" } -func getOrgSecrets(client httpClient, host, orgName string, showSelectedRepoInfo bool) ([]*Secret, error) { - secrets, err := getSecrets(client, host, fmt.Sprintf("orgs/%s/actions/secrets", orgName)) +func getOrgSecrets(client httpClient, host, orgName string, showSelectedRepoInfo bool, app shared.App) ([]*Secret, error) { + secrets, err := getSecrets(client, host, fmt.Sprintf("orgs/%s/%s/secrets", orgName, app)) if err != nil { return nil, err } @@ -215,9 +231,9 @@ func getEnvSecrets(client httpClient, repo ghrepo.Interface, envName string) ([] return getSecrets(client, repo.RepoHost(), path) } -func getRepoSecrets(client httpClient, repo ghrepo.Interface) ([]*Secret, error) { - return getSecrets(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets", - ghrepo.FullName(repo))) +func getRepoSecrets(client httpClient, repo ghrepo.Interface, app shared.App) ([]*Secret, error) { + return getSecrets(client, repo.RepoHost(), fmt.Sprintf("repos/%s/%s/secrets", + ghrepo.FullName(repo), app)) } type secretsPayload struct { diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go index bd14fafe9..6b1ffa253 100644 --- a/pkg/cmd/secret/list/list_test.go +++ b/pkg/cmd/secret/list/list_test.go @@ -54,6 +54,21 @@ func Test_NewCmdList(t *testing.T) { UserSecrets: true, }, }, + { + name: "Dependabot repo", + cli: "--app Dependabot", + wants: ListOptions{ + Application: "Dependabot", + }, + }, + { + name: "Dependabot org", + cli: "--app Dependabot --org UmbrellaCorporation", + wants: ListOptions{ + Application: "Dependabot", + OrgName: "UmbrellaCorporation", + }, + }, } for _, tt := range tests { @@ -184,6 +199,56 @@ func Test_listRun(t *testing.T) { "SECRET_THREE\t1975-11-30\t", }, }, + { + name: "Dependabot repo tty", + tty: true, + opts: &ListOptions{ + Application: "Dependabot", + }, + wantOut: []string{ + "SECRET_ONE.*Updated 1988-10-11", + "SECRET_TWO.*Updated 2020-12-04", + "SECRET_THREE.*Updated 1975-11-30", + }, + }, + { + name: "Dependabot repo not tty", + tty: false, + opts: &ListOptions{ + Application: "Dependabot", + }, + wantOut: []string{ + "SECRET_ONE\t1988-10-11", + "SECRET_TWO\t2020-12-04", + "SECRET_THREE\t1975-11-30", + }, + }, + { + name: "Dependabot org tty", + tty: true, + opts: &ListOptions{ + Application: "Dependabot", + OrgName: "UmbrellaCorporation", + }, + wantOut: []string{ + "SECRET_ONE.*Updated 1988-10-11.*Visible to all repositories", + "SECRET_TWO.*Updated 2020-12-04.*Visible to private repositories", + "SECRET_THREE.*Updated 1975-11-30.*Visible to 2 selected repositories", + }, + }, + { + name: "Dependabot org not tty", + tty: false, + opts: &ListOptions{ + Application: "Dependabot", + OrgName: "UmbrellaCorporation", + }, + wantOut: []string{ + "SECRET_ONE\t1988-10-11\tALL", + "SECRET_TWO\t2020-12-04\tPRIVATE", + "SECRET_THREE\t1975-11-30\tSELECTED", + }, + }, } for _, tt := range tests { @@ -280,6 +345,10 @@ func Test_listRun(t *testing.T) { } } + if tt.opts.Application == "Dependabot" { + path = strings.Replace(path, "actions", "dependabot", 1) + } + reg.Register(httpmock.REST("GET", path), httpmock.JSONResponse(payload)) io, _, stdout, _ := iostreams.Test() diff --git a/pkg/cmd/secret/remove/remove.go b/pkg/cmd/secret/remove/remove.go index 4ecf0b6cc..a4ec51277 100644 --- a/pkg/cmd/secret/remove/remove.go +++ b/pkg/cmd/secret/remove/remove.go @@ -8,6 +8,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/secret/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" "github.com/spf13/cobra" @@ -23,6 +24,7 @@ type RemoveOptions struct { OrgName string EnvName string UserSecrets bool + Application string } func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Command { @@ -37,9 +39,9 @@ func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Co Short: "Remove secrets", Long: heredoc.Doc(` Remove a secret on one of the following levels: - - repository (default): available to Actions runs in a repository + - repository (default): available to Actions runs or Dependabot in a repository - environment: available to Actions runs for a deployment environment in a repository - - organization: available to Actions runs within an organization + - organization: available to Actions runs or Dependabot within an organization - user: available to Codespaces for your user `), Args: cobra.ExactArgs(1), @@ -63,6 +65,7 @@ func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Co cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Remove a secret for an organization") cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Remove a secret for an environment") cmd.Flags().BoolVarP(&opts.UserSecrets, "user", "u", false, "Remove a secret for your user") + cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Codespaces, shared.Dependabot}, "Remove a secret for a specific application") return cmd } @@ -77,8 +80,22 @@ func removeRun(opts *RemoveOptions) error { orgName := opts.OrgName envName := opts.EnvName + secretEntity, err := shared.GetSecretEntity(orgName, envName, opts.UserSecrets) + if err != nil { + return err + } + + secretApp, err := shared.GetSecretApp(opts.Application, secretEntity) + if err != nil { + return err + } + + if !shared.IsSupportedSecretEntity(secretApp, secretEntity) { + return fmt.Errorf("%s secrets are not supported for %s", secretEntity, secretApp) + } + var baseRepo ghrepo.Interface - if orgName == "" && !opts.UserSecrets { + if secretEntity == shared.Repository || secretEntity == shared.Environment { baseRepo, err = opts.BaseRepo() if err != nil { return fmt.Errorf("could not determine base repo: %w", err) @@ -86,14 +103,15 @@ func removeRun(opts *RemoveOptions) error { } var path string - if orgName != "" { - path = fmt.Sprintf("orgs/%s/actions/secrets/%s", orgName, opts.SecretName) - } else if envName != "" { + switch secretEntity { + case shared.Organization: + path = fmt.Sprintf("orgs/%s/%s/secrets/%s", orgName, secretApp, opts.SecretName) + case shared.Environment: path = fmt.Sprintf("repos/%s/environments/%s/secrets/%s", ghrepo.FullName(baseRepo), envName, opts.SecretName) - } else if opts.UserSecrets { + case shared.User: path = fmt.Sprintf("user/codespaces/secrets/%s", opts.SecretName) - } else { - path = fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(baseRepo), opts.SecretName) + case shared.Repository: + path = fmt.Sprintf("repos/%s/%s/secrets/%s", ghrepo.FullName(baseRepo), secretApp, opts.SecretName) } cfg, err := opts.Config() @@ -112,17 +130,21 @@ func removeRun(opts *RemoveOptions) error { } if opts.IO.IsStdoutTTY() { - target := orgName - if opts.UserSecrets { + var target string + switch secretEntity { + case shared.Organization: + target = orgName + case shared.User: target = "your user" - } else if orgName == "" { + case shared.Repository, shared.Environment: target = ghrepo.FullName(baseRepo) } + cs := opts.IO.ColorScheme() if envName != "" { fmt.Fprintf(opts.IO.Out, "%s Removed secret %s from %s environment on %s\n", cs.SuccessIconWithColor(cs.Red), opts.SecretName, envName, target) } else { - fmt.Fprintf(opts.IO.Out, "%s Removed secret %s from %s\n", cs.SuccessIconWithColor(cs.Red), opts.SecretName, target) + fmt.Fprintf(opts.IO.Out, "%s Removed %s secret %s from %s\n", cs.SuccessIconWithColor(cs.Red), secretApp.Title(), opts.SecretName, target) } } diff --git a/pkg/cmd/secret/remove/remove_test.go b/pkg/cmd/secret/remove/remove_test.go index 2abb38c64..f2b1a4d8e 100644 --- a/pkg/cmd/secret/remove/remove_test.go +++ b/pkg/cmd/secret/remove/remove_test.go @@ -2,7 +2,6 @@ package remove import ( "bytes" - "fmt" "net/http" "testing" @@ -57,6 +56,23 @@ func TestNewCmdRemove(t *testing.T) { UserSecrets: true, }, }, + { + name: "Dependabot repo", + cli: "cool --app Dependabot", + wants: RemoveOptions{ + SecretName: "cool", + Application: "Dependabot", + }, + }, + { + name: "Dependabot org", + cli: "cool --app Dependabot --org UmbrellaCorporation", + wants: RemoveOptions{ + SecretName: "cool", + OrgName: "UmbrellaCorporation", + Application: "Dependabot", + }, + }, } for _, tt := range tests { @@ -95,32 +111,61 @@ func TestNewCmdRemove(t *testing.T) { } func Test_removeRun_repo(t *testing.T) { - reg := &httpmock.Registry{} - - reg.Register( - httpmock.REST("DELETE", "repos/owner/repo/actions/secrets/cool_secret"), - httpmock.StatusStringResponse(204, "No Content")) - - io, _, _, _ := iostreams.Test() - - opts := &RemoveOptions{ - IO: io, - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil + tests := []struct { + name string + opts *RemoveOptions + wantPath string + }{ + { + name: "Actions", + opts: &RemoveOptions{ + Application: "actions", + SecretName: "cool_secret", + }, + wantPath: "repos/owner/repo/actions/secrets/cool_secret", }, - Config: func() (config.Config, error) { - return config.NewBlankConfig(), nil + { + name: "Dependabot", + opts: &RemoveOptions{ + Application: "dependabot", + SecretName: "cool_dependabot_secret", + }, + wantPath: "repos/owner/repo/dependabot/secrets/cool_dependabot_secret", }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.FromFullName("owner/repo") + { + name: "defaults to Actions", + opts: &RemoveOptions{ + SecretName: "cool_secret", + }, + wantPath: "repos/owner/repo/actions/secrets/cool_secret", }, - SecretName: "cool_secret", } - err := removeRun(opts) - assert.NoError(t, err) + for _, tt := range tests { + reg := &httpmock.Registry{} - reg.Verify(t) + reg.Register( + httpmock.REST("DELETE", tt.wantPath), + httpmock.StatusStringResponse(204, "No Content")) + + io, _, _, _ := iostreams.Test() + + tt.opts.IO = io + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + tt.opts.Config = func() (config.Config, error) { + return config.NewBlankConfig(), nil + } + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + } + + err := removeRun(tt.opts) + assert.NoError(t, err) + + reg.Verify(t) + } } func Test_removeRun_env(t *testing.T) { @@ -155,18 +200,24 @@ func Test_removeRun_env(t *testing.T) { func Test_removeRun_org(t *testing.T) { tests := []struct { - name string - opts *RemoveOptions + name string + opts *RemoveOptions + wantPath string }{ - { - name: "repo", - opts: &RemoveOptions{}, - }, { name: "org", opts: &RemoveOptions{ OrgName: "UmbrellaCorporation", }, + wantPath: "orgs/UmbrellaCorporation/actions/secrets/tVirus", + }, + { + name: "Dependabot org", + opts: &RemoveOptions{ + Application: "dependabot", + OrgName: "UmbrellaCorporation", + }, + wantPath: "orgs/UmbrellaCorporation/dependabot/secrets/tVirus", }, } @@ -174,17 +225,9 @@ func Test_removeRun_org(t *testing.T) { t.Run(tt.name, func(t *testing.T) { reg := &httpmock.Registry{} - orgName := tt.opts.OrgName - - if orgName == "" { - reg.Register( - httpmock.REST("DELETE", "repos/owner/repo/actions/secrets/tVirus"), - httpmock.StatusStringResponse(204, "No Content")) - } else { - reg.Register( - httpmock.REST("DELETE", fmt.Sprintf("orgs/%s/actions/secrets/tVirus", orgName)), - httpmock.StatusStringResponse(204, "No Content")) - } + reg.Register( + httpmock.REST("DELETE", tt.wantPath), + httpmock.StatusStringResponse(204, "No Content")) io, _, _, _ := iostreams.Test() diff --git a/pkg/cmd/secret/secret.go b/pkg/cmd/secret/secret.go index 2ef5eeec6..94231b661 100644 --- a/pkg/cmd/secret/secret.go +++ b/pkg/cmd/secret/secret.go @@ -14,8 +14,9 @@ func NewCmdSecret(f *cmdutil.Factory) *cobra.Command { Use: "secret ", Short: "Manage GitHub secrets", Long: heredoc.Doc(` - Secrets can be set at the repository, environment, or organization level for use in - GitHub Actions. User secrets can be set for use in GitHub Codespaces. + Secrets can be set at the repository, or organization level for use in + GitHub Actions or Dependabot. User secrets can be set for use in GitHub Codespaces. + Environment secrets can be set for use in GitHub Actions. Run "gh help secret set" to learn how to get started. `), } diff --git a/pkg/cmd/secret/set/http.go b/pkg/cmd/secret/set/http.go index f74f757d9..f36c2b59e 100644 --- a/pkg/cmd/secret/set/http.go +++ b/pkg/cmd/secret/set/http.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/v2/api" "github.com/cli/cli/v2/internal/ghrepo" + "github.com/cli/cli/v2/pkg/cmd/secret/shared" ) type SecretPayload struct { @@ -40,17 +41,17 @@ func getPubKey(client *api.Client, host, path string) (*PubKey, error) { return &pk, nil } -func getOrgPublicKey(client *api.Client, host, orgName string) (*PubKey, error) { - return getPubKey(client, host, fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName)) +func getOrgPublicKey(client *api.Client, host, orgName string, app shared.App) (*PubKey, error) { + return getPubKey(client, host, fmt.Sprintf("orgs/%s/%s/secrets/public-key", orgName, app)) } func getUserPublicKey(client *api.Client, host string) (*PubKey, error) { return getPubKey(client, host, "user/codespaces/secrets/public-key") } -func getRepoPubKey(client *api.Client, repo ghrepo.Interface) (*PubKey, error) { - return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets/public-key", - ghrepo.FullName(repo))) +func getRepoPubKey(client *api.Client, repo ghrepo.Interface, app shared.App) (*PubKey, error) { + return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/%s/secrets/public-key", + ghrepo.FullName(repo), app)) } func getEnvPubKey(client *api.Client, repo ghrepo.Interface, envName string) (*PubKey, error) { @@ -68,14 +69,14 @@ func putSecret(client *api.Client, host, path string, payload interface{}) error return client.REST(host, "PUT", path, requestBody, nil) } -func putOrgSecret(client *api.Client, host string, pk *PubKey, orgName, visibility, secretName, eValue string, repositoryIDs []int64) error { +func putOrgSecret(client *api.Client, host string, pk *PubKey, orgName, visibility, secretName, eValue string, repositoryIDs []int64, app shared.App) error { payload := SecretPayload{ EncryptedValue: eValue, KeyID: pk.ID, Repositories: repositoryIDs, Visibility: visibility, } - path := fmt.Sprintf("orgs/%s/actions/secrets/%s", orgName, secretName) + path := fmt.Sprintf("orgs/%s/%s/secrets/%s", orgName, app, secretName) return putSecret(client, host, path, payload) } @@ -107,12 +108,12 @@ func putEnvSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, envName return putSecret(client, repo.RepoHost(), path, payload) } -func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secretName, eValue string) error { +func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secretName, eValue string, app shared.App) error { payload := SecretPayload{ EncryptedValue: eValue, KeyID: pk.ID, } - path := fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(repo), secretName) + path := fmt.Sprintf("repos/%s/%s/secrets/%s", ghrepo.FullName(repo), app, secretName) return putSecret(client, repo.RepoHost(), path, payload) } diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go index 0c46d5f70..c147a81f5 100644 --- a/pkg/cmd/secret/set/set.go +++ b/pkg/cmd/secret/set/set.go @@ -42,6 +42,7 @@ type SetOptions struct { Visibility string RepositoryNames []string EnvFile string + Application string } func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command { @@ -56,9 +57,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command Short: "Create or update secrets", Long: heredoc.Doc(` Set a value for a secret on one of the following levels: - - repository (default): available to Actions runs in a repository + - repository (default): available to Actions runs or Dependabot in a repository - environment: available to Actions runs for a deployment environment in a repository - - organization: available to Actions runs within an organization + - organization: available to Actions runs or Dependabot within an organization - user: available to Codespaces for your user Organization and user secrets can optionally be restricted to only be available to @@ -88,6 +89,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command # Set user-level secret for Codespaces $ gh secret set MYSECRET --user + # Set repository-level secret for Dependabot + $ gh secret set MYSECRET --app dependabot + # Set multiple secrets imported from the ".env" file $ gh secret set -f .env `), @@ -150,6 +154,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "The value for the secret (reads from standard input if not specified)") cmd.Flags().BoolVar(&opts.DoNotStore, "no-store", false, "Print the encrypted, base64-encoded value instead of storing it on Github") cmd.Flags().StringVarP(&opts.EnvFile, "env-file", "f", "", "Load secret names and values from a dotenv-formatted `file`") + cmdutil.StringEnumFlag(cmd, &opts.Application, "app", "a", "", []string{shared.Actions, shared.Codespaces, shared.Dependabot}, "Set the application for a secret") return cmd } @@ -189,6 +194,35 @@ func setRun(opts *SetOptions) error { } } + secretEntity, err := shared.GetSecretEntity(orgName, envName, opts.UserSecrets) + if err != nil { + return err + } + + secretApp, err := shared.GetSecretApp(opts.Application, secretEntity) + if err != nil { + return err + } + + if !shared.IsSupportedSecretEntity(secretApp, secretEntity) { + return fmt.Errorf("%s secrets are not supported for %s", secretEntity, secretApp) + } + + var pk *PubKey + switch secretEntity { + case shared.Organization: + pk, err = getOrgPublicKey(client, host, orgName, secretApp) + case shared.Environment: + pk, err = getEnvPubKey(client, baseRepo, envName) + case shared.User: + pk, err = getUserPublicKey(client, host) + default: + pk, err = getRepoPubKey(client, baseRepo, secretApp) + } + if err != nil { + return fmt.Errorf("failed to fetch public key: %w", err) + } + type repoNamesResult struct { ids []int64 err error @@ -206,20 +240,6 @@ func setRun(opts *SetOptions) error { } }() - var pk *PubKey - if orgName != "" { - pk, err = getOrgPublicKey(client, host, orgName) - } else if envName != "" { - pk, err = getEnvPubKey(client, baseRepo, envName) - } else if opts.UserSecrets { - pk, err = getUserPublicKey(client, host) - } else { - pk, err = getRepoPubKey(client, baseRepo) - } - if err != nil { - return fmt.Errorf("failed to fetch public key: %w", err) - } - var repositoryIDs []int64 if result := <-repoNamesC; result.err == nil { repositoryIDs = result.ids @@ -232,7 +252,7 @@ func setRun(opts *SetOptions) error { key := secretKey value := secret go func() { - setc <- setSecret(opts, pk, host, client, baseRepo, key, value, repositoryIDs) + setc <- setSecret(opts, pk, host, client, baseRepo, key, value, repositoryIDs, secretApp, secretEntity) }() } @@ -257,7 +277,7 @@ func setRun(opts *SetOptions) error { } else if orgName == "" { target = ghrepo.FullName(baseRepo) } - fmt.Fprintf(opts.IO.Out, "%s Set secret %s for %s\n", cs.SuccessIcon(), result.key, target) + fmt.Fprintf(opts.IO.Out, "%s Set %s secret %s for %s\n", cs.SuccessIcon(), secretApp.Title(), result.key, target) } return err } @@ -268,7 +288,7 @@ type setResult struct { err error } -func setSecret(opts *SetOptions, pk *PubKey, host string, client *api.Client, baseRepo ghrepo.Interface, secretKey string, secret []byte, repositoryIDs []int64) (res setResult) { +func setSecret(opts *SetOptions, pk *PubKey, host string, client *api.Client, baseRepo ghrepo.Interface, secretKey string, secret []byte, repositoryIDs []int64, app shared.App, entity shared.SecretEntity) (res setResult) { orgName := opts.OrgName envName := opts.EnvName res.key = secretKey @@ -297,14 +317,15 @@ func setSecret(opts *SetOptions, pk *PubKey, host string, client *api.Client, ba return } - if orgName != "" { - err = putOrgSecret(client, host, pk, opts.OrgName, opts.Visibility, secretKey, encoded, repositoryIDs) - } else if envName != "" { + switch entity { + case shared.Organization: + err = putOrgSecret(client, host, pk, orgName, opts.Visibility, secretKey, encoded, repositoryIDs, app) + case shared.Environment: err = putEnvSecret(client, pk, baseRepo, envName, secretKey, encoded) - } else if opts.UserSecrets { + case shared.User: err = putUserSecret(client, host, pk, secretKey, encoded, repositoryIDs) - } else { - err = putRepoSecret(client, pk, baseRepo, secretKey, encoded) + default: + err = putRepoSecret(client, pk, baseRepo, secretKey, encoded, app) } if err != nil { res.err = fmt.Errorf("failed to set secret %q: %w", secretKey, err) diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go index 7843a8b3b..72640a86a 100644 --- a/pkg/cmd/secret/set/set_test.go +++ b/pkg/cmd/secret/set/set_test.go @@ -143,6 +143,29 @@ func TestNewCmdSet(t *testing.T) { DoNotStore: true, }, }, + { + name: "Dependabot repo", + cli: `cool_secret -b"a secret" --app Dependabot`, + wants: SetOptions{ + SecretName: "cool_secret", + Visibility: shared.Private, + Body: "a secret", + OrgName: "", + Application: "Dependabot", + }, + }, + { + name: "Dependabot org", + cli: "-ocoolOrg -bs -vselected -rcoolRepo cool_secret -aDependabot", + wants: SetOptions{ + SecretName: "cool_secret", + Visibility: shared.Selected, + RepositoryNames: []string{"coolRepo"}, + Body: "s", + OrgName: "coolOrg", + Application: "Dependabot", + }, + }, } for _, tt := range tests { @@ -181,46 +204,81 @@ func TestNewCmdSet(t *testing.T) { assert.Equal(t, tt.wants.EnvName, gotOpts.EnvName) assert.Equal(t, tt.wants.DoNotStore, gotOpts.DoNotStore) assert.ElementsMatch(t, tt.wants.RepositoryNames, gotOpts.RepositoryNames) + assert.Equal(t, tt.wants.Application, gotOpts.Application) }) } } func Test_setRun_repo(t *testing.T) { - reg := &httpmock.Registry{} - - reg.Register(httpmock.REST("GET", "repos/owner/repo/actions/secrets/public-key"), - httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="})) - - reg.Register(httpmock.REST("PUT", "repos/owner/repo/actions/secrets/cool_secret"), httpmock.StatusStringResponse(201, `{}`)) - - io, _, _, _ := iostreams.Test() - - opts := &SetOptions{ - HttpClient: func() (*http.Client, error) { - return &http.Client{Transport: reg}, nil + tests := []struct { + name string + opts *SetOptions + wantApp string + }{ + { + name: "Actions", + opts: &SetOptions{ + Application: "actions", + }, + wantApp: "actions", }, - Config: func() (config.Config, error) { return config.NewBlankConfig(), nil }, - BaseRepo: func() (ghrepo.Interface, error) { - return ghrepo.FromFullName("owner/repo") + { + name: "Dependabot", + opts: &SetOptions{ + Application: "dependabot", + }, + wantApp: "dependabot", + }, + { + name: "defaults to Actions", + opts: &SetOptions{ + Application: "", + }, + wantApp: "actions", }, - IO: io, - SecretName: "cool_secret", - Body: "a secret", - RandomOverride: fakeRandom, } - err := setRun(opts) - assert.NoError(t, err) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} - reg.Verify(t) + reg.Register(httpmock.REST("GET", fmt.Sprintf("repos/owner/repo/%s/secrets/public-key", tt.wantApp)), + httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="})) - data, err := ioutil.ReadAll(reg.Requests[1].Body) - assert.NoError(t, err) - var payload SecretPayload - err = json.Unmarshal(data, &payload) - assert.NoError(t, err) - assert.Equal(t, payload.KeyID, "123") - assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=") + reg.Register(httpmock.REST("PUT", fmt.Sprintf("repos/owner/repo/%s/secrets/cool_secret", tt.wantApp)), + httpmock.StatusStringResponse(201, `{}`)) + + io, _, _, _ := iostreams.Test() + + opts := &SetOptions{ + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Config: func() (config.Config, error) { return config.NewBlankConfig(), nil }, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("owner/repo") + }, + IO: io, + SecretName: "cool_secret", + Body: "a secret", + RandomOverride: fakeRandom, + Application: tt.opts.Application, + } + + err := setRun(opts) + assert.NoError(t, err) + + reg.Verify(t) + + data, err := ioutil.ReadAll(reg.Requests[1].Body) + assert.NoError(t, err) + var payload SecretPayload + err = json.Unmarshal(data, &payload) + assert.NoError(t, err) + assert.Equal(t, payload.KeyID, "123") + assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=") + }) + } } func Test_setRun_env(t *testing.T) { @@ -268,6 +326,7 @@ func Test_setRun_org(t *testing.T) { opts *SetOptions wantVisibility shared.Visibility wantRepositories []int64 + wantApp string }{ { name: "all vis", @@ -275,6 +334,7 @@ func Test_setRun_org(t *testing.T) { OrgName: "UmbrellaCorporation", Visibility: shared.All, }, + wantApp: "actions", }, { name: "selected visibility", @@ -284,6 +344,16 @@ func Test_setRun_org(t *testing.T) { RepositoryNames: []string{"birkin", "UmbrellaCorporation/wesker"}, }, wantRepositories: []int64{1, 2}, + wantApp: "actions", + }, + { + name: "Dependabot", + opts: &SetOptions{ + OrgName: "UmbrellaCorporation", + Visibility: shared.All, + Application: "dependabot", + }, + wantApp: "dependabot", }, } @@ -294,11 +364,11 @@ func Test_setRun_org(t *testing.T) { orgName := tt.opts.OrgName reg.Register(httpmock.REST("GET", - fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName)), + fmt.Sprintf("orgs/%s/%s/secrets/public-key", orgName, tt.wantApp)), httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="})) reg.Register(httpmock.REST("PUT", - fmt.Sprintf("orgs/%s/actions/secrets/cool_secret", orgName)), + fmt.Sprintf("orgs/%s/%s/secrets/cool_secret", orgName, tt.wantApp)), httpmock.StatusStringResponse(201, `{}`)) if len(tt.opts.RepositoryNames) > 0 { diff --git a/pkg/cmd/secret/shared/shared.go b/pkg/cmd/secret/shared/shared.go index 4f58dd971..4e5e70617 100644 --- a/pkg/cmd/secret/shared/shared.go +++ b/pkg/cmd/secret/shared/shared.go @@ -1,5 +1,11 @@ package shared +import ( + "errors" + "fmt" + "strings" +) + type Visibility string const ( @@ -7,3 +13,80 @@ const ( Private = "private" Selected = "selected" ) + +type App string + +const ( + Actions = "actions" + Codespaces = "codespaces" + Dependabot = "dependabot" + Unknown = "unknown" +) + +func (app App) String() string { + return string(app) +} + +func (app App) Title() string { + return strings.Title(app.String()) +} + +type SecretEntity string + +const ( + Repository = "repository" + Organization = "organization" + User = "user" + Environment = "environment" +) + +func GetSecretEntity(orgName, envName string, userSecrets bool) (SecretEntity, error) { + orgSet := orgName != "" + envSet := envName != "" + + if orgSet && envSet || orgSet && userSecrets || envSet && userSecrets { + return "", errors.New("cannot specify multiple secret entities") + } + + if orgSet { + return Organization, nil + } + if envSet { + return Environment, nil + } + if userSecrets { + return User, nil + } + return Repository, nil +} + +func GetSecretApp(app string, entity SecretEntity) (App, error) { + switch strings.ToLower(app) { + case Actions: + return Actions, nil + case Codespaces: + return Codespaces, nil + case Dependabot: + return Dependabot, nil + case "": + if entity == User { + return Codespaces, nil + } + return Actions, nil + default: + return Unknown, fmt.Errorf("invalid application: %s", app) + } +} + +func IsSupportedSecretEntity(app App, entity SecretEntity) bool { + switch app { + case Actions: + return entity == Repository || entity == Organization || entity == Environment + case Codespaces: + return entity == User + case Dependabot: + return entity == Repository || entity == Organization + default: + return false + } +} diff --git a/pkg/cmd/secret/shared/shared_test.go b/pkg/cmd/secret/shared/shared_test.go new file mode 100644 index 000000000..b247a85d9 --- /dev/null +++ b/pkg/cmd/secret/shared/shared_test.go @@ -0,0 +1,203 @@ +package shared + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetSecretEntity(t *testing.T) { + tests := []struct { + name string + orgName string + envName string + userSecrets bool + want SecretEntity + wantErr bool + }{ + { + name: "org", + orgName: "myOrg", + want: Organization, + }, + { + name: "env", + envName: "myEnv", + want: Environment, + }, + { + name: "user", + userSecrets: true, + want: User, + }, + { + name: "defaults to repo", + want: Repository, + }, + { + name: "Errors if both org and env are set", + orgName: "myOrg", + envName: "myEnv", + wantErr: true, + }, + { + name: "Errors if both org and user secrets are set", + orgName: "myOrg", + userSecrets: true, + wantErr: true, + }, + { + name: "Errors if both env and user secrets are set", + envName: "myEnv", + userSecrets: true, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entity, err := GetSecretEntity(tt.orgName, tt.envName, tt.userSecrets) + + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want, entity) + } + }) + } +} + +func TestGetSecretApp(t *testing.T) { + tests := []struct { + name string + app string + entity SecretEntity + want App + wantErr bool + }{ + { + name: "Actions", + app: "actions", + want: Actions, + }, + { + name: "Codespaces", + app: "codespaces", + want: Codespaces, + }, + { + name: "Dependabot", + app: "dependabot", + want: Dependabot, + }, + { + name: "Defaults to Actions for repository", + app: "", + entity: Repository, + want: Actions, + }, + { + name: "Defaults to Actions for organization", + app: "", + entity: Organization, + want: Actions, + }, + { + name: "Defaults to Actions for environment", + app: "", + entity: Environment, + want: Actions, + }, + { + name: "Defaults to Codespaces for user", + app: "", + entity: User, + want: Codespaces, + }, + { + name: "Unknown for invalid apps", + app: "invalid", + want: Unknown, + wantErr: true, + }, + { + name: "case insensitive", + app: "ACTIONS", + want: Actions, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app, err := GetSecretApp(tt.app, tt.entity) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tt.want, app) + }) + } +} + +func TestIsSupportedSecretEntity(t *testing.T) { + tests := []struct { + name string + app App + supportedEntities []SecretEntity + unsupportedEntities []SecretEntity + }{ + { + name: "Actions", + app: Actions, + supportedEntities: []SecretEntity{ + Repository, + Organization, + Environment, + }, + unsupportedEntities: []SecretEntity{ + User, + Unknown, + }, + }, + { + name: "Codespaces", + app: Codespaces, + supportedEntities: []SecretEntity{ + User, + }, + unsupportedEntities: []SecretEntity{ + Repository, + Organization, + Environment, + Unknown, + }, + }, + { + name: "Dependabot", + app: Dependabot, + supportedEntities: []SecretEntity{ + Repository, + Organization, + }, + unsupportedEntities: []SecretEntity{ + Environment, + User, + Unknown, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, entity := range tt.supportedEntities { + assert.True(t, IsSupportedSecretEntity(tt.app, entity)) + } + + for _, entity := range tt.unsupportedEntities { + assert.False(t, IsSupportedSecretEntity(tt.app, entity)) + } + }) + } +} diff --git a/pkg/cmdutil/flags.go b/pkg/cmdutil/flags.go index dee70069e..2a73a79de 100644 --- a/pkg/cmdutil/flags.go +++ b/pkg/cmdutil/flags.go @@ -34,6 +34,16 @@ func StringEnumFlag(cmd *cobra.Command, p *string, name, shorthand, defaultValue return f } +func StringSliceEnumFlag(cmd *cobra.Command, p *[]string, name, shorthand string, defaultValues, options []string, usage string) *pflag.Flag { + *p = defaultValues + val := &enumMultiValue{value: p, options: options} + f := cmd.Flags().VarPF(val, name, shorthand, fmt.Sprintf("%s: %s", usage, formatValuesForUsageDocs(options))) + _ = cmd.RegisterFlagCompletionFunc(name, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return options, cobra.ShellCompDirectiveNoFileComp + }) + return f +} + func formatValuesForUsageDocs(values []string) string { return fmt.Sprintf("{%s}", strings.Join(values, "|")) } @@ -99,14 +109,7 @@ type enumValue struct { } func (e *enumValue) Set(value string) error { - found := false - for _, opt := range e.options { - if strings.EqualFold(opt, value) { - found = true - break - } - } - if !found { + if !isIncluded(value, e.options) { return fmt.Errorf("valid values are %s", formatValuesForUsageDocs(e.options)) } *e.string = value @@ -120,3 +123,39 @@ func (e *enumValue) String() string { func (e *enumValue) Type() string { return "string" } + +type enumMultiValue struct { + value *[]string + options []string +} + +func (e *enumMultiValue) Set(value string) error { + items := strings.Split(value, ",") + for _, item := range items { + if !isIncluded(item, e.options) { + return fmt.Errorf("valid values are %s", formatValuesForUsageDocs(e.options)) + } + } + *e.value = append(*e.value, items...) + return nil +} + +func (e *enumMultiValue) String() string { + if len(*e.value) == 0 { + return "" + } + return fmt.Sprintf("{%s}", strings.Join(*e.value, ", ")) +} + +func (e *enumMultiValue) Type() string { + return "stringSlice" +} + +func isIncluded(value string, opts []string) bool { + for _, opt := range opts { + if strings.EqualFold(opt, value) { + return true + } + } + return false +} diff --git a/pkg/httpmock/stub.go b/pkg/httpmock/stub.go index 5633c2caf..8970e8f49 100644 --- a/pkg/httpmock/stub.go +++ b/pkg/httpmock/stub.go @@ -6,6 +6,7 @@ import ( "io" "io/ioutil" "net/http" + "net/url" "os" "regexp" "strings" @@ -56,6 +57,24 @@ func GraphQL(q string) Matcher { } } +func QueryMatcher(method string, path string, query url.Values) Matcher { + return func(req *http.Request) bool { + if !REST(method, path)(req) { + return false + } + + actualQuery := req.URL.Query() + + for param := range query { + if !(actualQuery.Get(param) == query.Get(param)) { + return false + } + } + + return true + } +} + func readBody(req *http.Request) ([]byte, error) { bodyCopy := &bytes.Buffer{} r := io.TeeReader(req.Body, bodyCopy) diff --git a/pkg/liveshare/client.go b/pkg/liveshare/client.go index 9427ddf05..b67e1b1cf 100644 --- a/pkg/liveshare/client.go +++ b/pkg/liveshare/client.go @@ -57,7 +57,13 @@ func (opts *Options) uri(action string) (string, error) { sas := url.QueryEscape(opts.RelaySAS) uri := opts.RelayEndpoint - uri = strings.Replace(uri, "sb:", "wss:", -1) + + if strings.HasPrefix(uri, "http:") { + uri = strings.Replace(uri, "http:", "ws:", 1) + } else { + uri = strings.Replace(uri, "sb:", "wss:", -1) + } + uri = strings.Replace(uri, ".net/", ".net:443/$hc/", 1) uri = uri + "?sb-hc-action=" + action + "&sb-hc-token=" + sas return uri, nil diff --git a/pkg/liveshare/client_test.go b/pkg/liveshare/client_test.go index c9c03aa3d..4b2908858 100644 --- a/pkg/liveshare/client_test.go +++ b/pkg/liveshare/client_test.go @@ -22,7 +22,7 @@ func TestConnect(t *testing.T) { HostPublicKeys: []string{livesharetest.SSHPublicKey}, Logger: newMockLogger(), } - joinWorkspace := func(req *jsonrpc2.Request) (interface{}, error) { + joinWorkspace := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) { var joinWorkspaceReq joinWorkspaceArgs if err := json.Unmarshal(*req.Params, &joinWorkspaceReq); err != nil { return nil, fmt.Errorf("error unmarshaling req: %w", err) diff --git a/pkg/liveshare/port_forwarder.go b/pkg/liveshare/port_forwarder.go index 2649abd3c..ba2c7ff40 100644 --- a/pkg/liveshare/port_forwarder.go +++ b/pkg/liveshare/port_forwarder.go @@ -97,6 +97,7 @@ func (fwd *PortForwarder) shareRemotePort(ctx context.Context) (channelID, error if err != nil { err = fmt.Errorf("failed to share remote port %d: %w", fwd.remotePort, err) } + return id, err } diff --git a/pkg/liveshare/port_forwarder_test.go b/pkg/liveshare/port_forwarder_test.go index 0923847e0..93f8187d8 100644 --- a/pkg/liveshare/port_forwarder_test.go +++ b/pkg/liveshare/port_forwarder_test.go @@ -26,12 +26,27 @@ func TestNewPortForwarder(t *testing.T) { } } +type portUpdateNotification struct { + PortNotification + conn *jsonrpc2.Conn +} + func TestPortForwarderStart(t *testing.T) { streamName, streamCondition := "stream-name", "stream-condition" - serverSharing := func(req *jsonrpc2.Request) (interface{}, error) { + const port = 8000 + sendNotification := make(chan portUpdateNotification) + serverSharing := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) { + // Send the PortNotification that will be awaited on in session.StartSharing + sendNotification <- portUpdateNotification{ + PortNotification: PortNotification{ + Port: port, + ChangeKind: PortChangeKindStart, + }, + conn: conn, + } return Port{StreamName: streamName, StreamCondition: streamCondition}, nil } - getStream := func(req *jsonrpc2.Request) (interface{}, error) { + getStream := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) { return "stream-id", nil } @@ -55,31 +70,42 @@ func TestPortForwarderStart(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - done := make(chan error) go func() { - const name, remote = "ssh", 8000 - done <- NewPortForwarder(session, name, remote, false).ForwardToListener(ctx, listen) + notif := <-sendNotification + _, _ = notif.conn.DispatchCall(context.Background(), "serverSharing.sharingSucceeded", notif) + }() + + done := make(chan error, 2) + go func() { + done <- NewPortForwarder(session, "ssh", port, false).ForwardToListener(ctx, listen) }() go func() { var conn net.Conn - retries := 0 - for conn == nil && retries < 2 { + + // We retry DialTimeout in a loop to deal with a race in PortForwarder startup. + for tries := 0; conn == nil && tries < 2; tries++ { conn, err = net.DialTimeout("tcp", ":8000", 2*time.Second) - time.Sleep(1 * time.Second) + if conn == nil { + time.Sleep(1 * time.Second) + } } if conn == nil { done <- errors.New("failed to connect to forwarded port") + return } b := make([]byte, len("stream-data")) if _, err := conn.Read(b); err != nil && err != io.EOF { done <- fmt.Errorf("reading stream: %w", err) + return } if string(b) != "stream-data" { done <- fmt.Errorf("stream data is not expected value, got: %s", string(b)) + return } if _, err := conn.Write([]byte("new-data")); err != nil { done <- fmt.Errorf("writing to stream: %w", err) + return } done <- nil }() diff --git a/pkg/liveshare/ports.go b/pkg/liveshare/ports.go new file mode 100644 index 000000000..851fe3190 --- /dev/null +++ b/pkg/liveshare/ports.go @@ -0,0 +1,133 @@ +package liveshare + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/sourcegraph/jsonrpc2" + "golang.org/x/sync/errgroup" +) + +// Port describes a port exposed by the container. +type Port struct { + SourcePort int `json:"sourcePort"` + DestinationPort int `json:"destinationPort"` + SessionName string `json:"sessionName"` + StreamName string `json:"streamName"` + StreamCondition string `json:"streamCondition"` + BrowseURL string `json:"browseUrl"` + IsPublic bool `json:"isPublic"` + IsTCPServerConnectionEstablished bool `json:"isTCPServerConnectionEstablished"` + HasTLSHandshakePassed bool `json:"hasTLSHandshakePassed"` + Privacy string `json:"privacy"` +} + +type PortChangeKind string + +const ( + PortChangeKindStart PortChangeKind = "start" + PortChangeKindUpdate PortChangeKind = "update" +) + +// startSharing tells the Live Share host to start sharing the specified port from the container. +// The sessionName describes the purpose of the remote port or service. +// It returns an identifier that can be used to open an SSH channel to the remote port. +func (s *Session) startSharing(ctx context.Context, sessionName string, port int) (channelID, error) { + args := []interface{}{port, sessionName, fmt.Sprintf("http://localhost:%d", port)} + g, ctx := errgroup.WithContext(ctx) + + g.Go(func() error { + startNotification, err := s.WaitForPortNotification(ctx, port, PortChangeKindStart) + if err != nil { + return fmt.Errorf("error while waiting for port notification: %w", err) + + } + if !startNotification.Success { + return fmt.Errorf("error while starting port sharing: %s", startNotification.ErrorDetail) + } + return nil // success + }) + + var response Port + g.Go(func() error { + return s.rpc.do(ctx, "serverSharing.startSharing", args, &response) + }) + + if err := g.Wait(); err != nil { + return channelID{}, err + } + + return channelID{response.StreamName, response.StreamCondition}, nil +} + +type PortNotification struct { + Success bool // Helps us disambiguate between the SharingSucceeded/SharingFailed events + // The following are properties included in the SharingSucceeded/SharingFailed events sent by the server sharing service in the Codespace + Port int `json:"port"` + ChangeKind PortChangeKind `json:"changeKind"` + ErrorDetail string `json:"errorDetail"` + StatusCode int `json:"statusCode"` +} + +// WaitForPortNotification waits for a port notification to be received. It returns the notification +// or an error if the notification is not received before the context is cancelled or it fails +// to parse the notification. +func (s *Session) WaitForPortNotification(ctx context.Context, port int, notifType PortChangeKind) (*PortNotification, error) { + // We use 1-buffered channels and non-blocking sends so that + // no goroutine gets stuck. + notificationCh := make(chan *PortNotification, 1) + errCh := make(chan error, 1) + + h := func(success bool) func(*jsonrpc2.Conn, *jsonrpc2.Request) { + return func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) { + notification := new(PortNotification) + if err := json.Unmarshal(*req.Params, ¬ification); err != nil { + select { + case errCh <- fmt.Errorf("error unmarshaling notification: %w", err): + default: + } + return + } + notification.Success = success + if notification.Port == port && notification.ChangeKind == notifType { + select { + case notificationCh <- notification: + default: + } + } + } + } + deregisterSuccess := s.registerRequestHandler("serverSharing.sharingSucceeded", h(true)) + deregisterFailure := s.registerRequestHandler("serverSharing.sharingFailed", h(false)) + defer deregisterSuccess() + defer deregisterFailure() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case err := <-errCh: + return nil, err + case notification := <-notificationCh: + return notification, nil + } + } +} + +// GetSharedServers returns a description of each container port +// shared by a prior call to StartSharing by some client. +func (s *Session) GetSharedServers(ctx context.Context) ([]*Port, error) { + var response []*Port + if err := s.rpc.do(ctx, "serverSharing.getSharedServers", []string{}, &response); err != nil { + return nil, err + } + + return response, nil +} + +// UpdateSharedServerPrivacy controls port permissions and visibility scopes for who can access its URLs +// in the browser. +func (s *Session) UpdateSharedServerPrivacy(ctx context.Context, port int, visibility string) error { + return s.rpc.do(ctx, "serverSharing.updateSharedServerPrivacy", []interface{}{port, visibility}, nil) +} diff --git a/pkg/liveshare/rpc.go b/pkg/liveshare/rpc.go index 4ab8fbb88..639f538c9 100644 --- a/pkg/liveshare/rpc.go +++ b/pkg/liveshare/rpc.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "sync" "time" "github.com/opentracing/opentracing-go" @@ -12,16 +13,18 @@ import ( type rpcClient struct { *jsonrpc2.Conn - conn io.ReadWriteCloser + conn io.ReadWriteCloser + handlersMu sync.Mutex + handlers map[string][]*handlerWrapper } func newRPCClient(conn io.ReadWriteCloser) *rpcClient { - return &rpcClient{conn: conn} + return &rpcClient{conn: conn, handlers: make(map[string][]*handlerWrapper)} } func (r *rpcClient) connect(ctx context.Context) { stream := jsonrpc2.NewBufferedStream(r.conn, jsonrpc2.VSCodeObjectCodec{}) - r.Conn = jsonrpc2.NewConn(ctx, stream, nullHandler{}) + r.Conn = jsonrpc2.NewConn(ctx, stream, r) } func (r *rpcClient) do(ctx context.Context, method string, args, result interface{}) error { @@ -40,7 +43,45 @@ func (r *rpcClient) do(ctx context.Context, method string, args, result interfac return waiter.Wait(waitCtx, result) } -type nullHandler struct{} +type handler func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) -func (nullHandler) Handle(ctx context.Context, conn *jsonrpc2.Conn, req *jsonrpc2.Request) { +type handlerWrapper struct { + fn handler +} + +func (r *rpcClient) register(requestType string, fn handler) func() { + r.handlersMu.Lock() + defer r.handlersMu.Unlock() + + h := &handlerWrapper{fn: fn} + r.handlers[requestType] = append(r.handlers[requestType], h) + + return func() { + r.deregister(requestType, h) + } +} + +func (r *rpcClient) deregister(requestType string, handler *handlerWrapper) { + r.handlersMu.Lock() + defer r.handlersMu.Unlock() + + handlers := r.handlers[requestType] + for i, h := range handlers { + if h == handler { + // Swap h with last element and pop. + last := len(handlers) - 1 + handlers[i], handlers[last] = handlers[last], nil + r.handlers[requestType] = handlers[:last] + break + } + } +} + +func (r *rpcClient) Handle(ctx context.Context, conn *jsonrpc2.Conn, req *jsonrpc2.Request) { + r.handlersMu.Lock() + defer r.handlersMu.Unlock() + + for _, handler := range r.handlers[req.Method] { + go handler.fn(conn, req) + } } diff --git a/pkg/liveshare/session.go b/pkg/liveshare/session.go index b4bc3c16f..1bffa096f 100644 --- a/pkg/liveshare/session.go +++ b/pkg/liveshare/session.go @@ -30,52 +30,10 @@ func (s *Session) Close() error { return nil } -// Port describes a port exposed by the container. -type Port struct { - SourcePort int `json:"sourcePort"` - DestinationPort int `json:"destinationPort"` - SessionName string `json:"sessionName"` - StreamName string `json:"streamName"` - StreamCondition string `json:"streamCondition"` - BrowseURL string `json:"browseUrl"` - IsPublic bool `json:"isPublic"` - IsTCPServerConnectionEstablished bool `json:"isTCPServerConnectionEstablished"` - HasTLSHandshakePassed bool `json:"hasTLSHandshakePassed"` - Privacy string `json:"privacy"` -} - -// startSharing tells the Live Share host to start sharing the specified port from the container. -// The sessionName describes the purpose of the remote port or service. -// It returns an identifier that can be used to open an SSH channel to the remote port. -func (s *Session) startSharing(ctx context.Context, sessionName string, port int) (channelID, error) { - args := []interface{}{port, sessionName, fmt.Sprintf("http://localhost:%d", port)} - var response Port - if err := s.rpc.do(ctx, "serverSharing.startSharing", args, &response); err != nil { - return channelID{}, err - } - - return channelID{response.StreamName, response.StreamCondition}, nil -} - -// GetSharedServers returns a description of each container port -// shared by a prior call to StartSharing by some client. -func (s *Session) GetSharedServers(ctx context.Context) ([]*Port, error) { - var response []*Port - if err := s.rpc.do(ctx, "serverSharing.getSharedServers", []string{}, &response); err != nil { - return nil, err - } - - return response, nil -} - -// UpdateSharedServerPrivacy controls port permissions and visibility scopes for who can access its URLs -// in the browser. -func (s *Session) UpdateSharedServerPrivacy(ctx context.Context, port int, visibility string) error { - if err := s.rpc.do(ctx, "serverSharing.updateSharedServerPrivacy", []interface{}{port, visibility}, nil); err != nil { - return err - } - - return nil +// registerRequestHandler registers a handler for the given request type with the RPC +// server and returns a callback function to deregister the handler +func (s *Session) registerRequestHandler(requestType string, h handler) func() { + return s.rpc.register(requestType, h) } // StartsSSHServer starts an SSH server in the container, installing sshd if necessary, diff --git a/pkg/liveshare/session_test.go b/pkg/liveshare/session_test.go index 44cb2357b..c06a4f06a 100644 --- a/pkg/liveshare/session_test.go +++ b/pkg/liveshare/session_test.go @@ -19,7 +19,7 @@ import ( const mockClientName = "liveshare-client" func makeMockSession(opts ...livesharetest.ServerOption) (*livesharetest.Server, *Session, error) { - joinWorkspace := func(req *jsonrpc2.Request) (interface{}, error) { + joinWorkspace := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) { return joinWorkspaceResult{1}, nil } const sessionToken = "session-token" @@ -51,7 +51,8 @@ func makeMockSession(opts ...livesharetest.ServerOption) (*livesharetest.Server, func TestServerStartSharing(t *testing.T) { serverPort, serverProtocol := 2222, "sshd" - startSharing := func(req *jsonrpc2.Request) (interface{}, error) { + sendNotification := make(chan portUpdateNotification) + startSharing := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) { var args []interface{} if err := json.Unmarshal(*req.Params, &args); err != nil { return nil, fmt.Errorf("error unmarshaling request: %w", err) @@ -59,9 +60,11 @@ func TestServerStartSharing(t *testing.T) { if len(args) < 3 { return nil, errors.New("not enough arguments to start sharing") } - if port, ok := args[0].(float64); !ok { + port, ok := args[0].(float64) + if !ok { return nil, errors.New("port argument is not an int") - } else if port != float64(serverPort) { + } + if port != float64(serverPort) { return nil, errors.New("port does not match serverPort") } if protocol, ok := args[1].(string); !ok { @@ -74,6 +77,13 @@ func TestServerStartSharing(t *testing.T) { } else if browseURL != fmt.Sprintf("http://localhost:%d", serverPort) { return nil, errors.New("browseURL does not match expected") } + sendNotification <- portUpdateNotification{ + PortNotification: PortNotification{ + Port: int(port), + ChangeKind: PortChangeKindStart, + }, + conn: conn, + } return Port{StreamName: "stream-name", StreamCondition: "stream-condition"}, nil } testServer, session, err := makeMockSession( @@ -86,6 +96,11 @@ func TestServerStartSharing(t *testing.T) { } ctx := context.Background() + go func() { + notif := <-sendNotification + _, _ = notif.conn.DispatchCall(context.Background(), "serverSharing.sharingSucceeded", notif) + }() + done := make(chan error) go func() { streamID, err := session.startSharing(ctx, serverProtocol, serverPort) @@ -114,7 +129,7 @@ func TestServerGetSharedServers(t *testing.T) { StreamName: "stream-name", StreamCondition: "stream-condition", } - getSharedServers := func(req *jsonrpc2.Request) (interface{}, error) { + getSharedServers := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) { return []*Port{&sharedServer}, nil } testServer, session, err := makeMockSession( @@ -157,7 +172,7 @@ func TestServerGetSharedServers(t *testing.T) { } func TestServerUpdateSharedServerPrivacy(t *testing.T) { - updateSharedVisibility := func(rpcReq *jsonrpc2.Request) (interface{}, error) { + updateSharedVisibility := func(conn *jsonrpc2.Conn, rpcReq *jsonrpc2.Request) (interface{}, error) { var req []interface{} if err := json.Unmarshal(*rpcReq.Params, &req); err != nil { return nil, fmt.Errorf("unmarshal req: %w", err) @@ -204,7 +219,7 @@ func TestServerUpdateSharedServerPrivacy(t *testing.T) { } func TestInvalidHostKey(t *testing.T) { - joinWorkspace := func(req *jsonrpc2.Request) (interface{}, error) { + joinWorkspace := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) { return joinWorkspaceResult{1}, nil } const sessionToken = "session-token" @@ -240,7 +255,7 @@ func TestKeepAliveNonBlocking(t *testing.T) { } func TestNotifyHostOfActivity(t *testing.T) { - notifyHostOfActivity := func(rpcReq *jsonrpc2.Request) (interface{}, error) { + notifyHostOfActivity := func(conn *jsonrpc2.Conn, rpcReq *jsonrpc2.Request) (interface{}, error) { var req []interface{} if err := json.Unmarshal(*rpcReq.Params, &req); err != nil { return nil, fmt.Errorf("unmarshal req: %w", err) @@ -299,7 +314,7 @@ func TestSessionHeartbeat(t *testing.T) { wg sync.WaitGroup ) wg.Add(1) - notifyHostOfActivity := func(rpcReq *jsonrpc2.Request) (interface{}, error) { + notifyHostOfActivity := func(conn *jsonrpc2.Conn, rpcReq *jsonrpc2.Request) (interface{}, error) { defer wg.Done() requestsMu.Lock() requests++ diff --git a/pkg/liveshare/test/server.go b/pkg/liveshare/test/server.go index 3f038a15b..5dd4e56aa 100644 --- a/pkg/liveshare/test/server.go +++ b/pkg/liveshare/test/server.go @@ -42,6 +42,7 @@ type Server struct { sshConfig *ssh.ServerConfig httptestServer *httptest.Server errCh chan error + nonSecure bool } // NewServer creates a new Server. ServerOptions can be passed to configure @@ -65,7 +66,12 @@ func NewServer(opts ...ServerOption) (*Server, error) { server.sshConfig.AddHostKey(privateKey) server.errCh = make(chan error, 1) - server.httptestServer = httptest.NewTLSServer(http.HandlerFunc(makeConnection(server))) + + if server.nonSecure { + server.httptestServer = httptest.NewServer(http.HandlerFunc(makeConnection(server))) + } else { + server.httptestServer = httptest.NewTLSServer(http.HandlerFunc(makeConnection(server))) + } return server, nil } @@ -80,6 +86,14 @@ func WithPassword(password string) ServerOption { } } +// WithNonSecure configures the Server as non-secure. +func WithNonSecure() ServerOption { + return func(s *Server) error { + s.nonSecure = true + return nil + } +} + // WithService accepts a mock RPC service for the Server to invoke. func WithService(serviceName string, handler RPCHandleFunc) ServerOption { return func(s *Server) error { @@ -303,7 +317,7 @@ func handleChannel(server *Server, channel ssh.Channel) { jsonrpc2.NewConn(context.Background(), stream, newRPCHandler(server)) } -type RPCHandleFunc func(req *jsonrpc2.Request) (interface{}, error) +type RPCHandleFunc func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) type rpcHandler struct { server *Server @@ -322,7 +336,7 @@ func (r *rpcHandler) Handle(ctx context.Context, conn *jsonrpc2.Conn, req *jsonr return } - result, err := handler(req) + result, err := handler(conn, req) if err != nil { sendError(r.server.errCh, fmt.Errorf("error handling: '%s': %w", req.Method, err)) return diff --git a/pkg/search/query.go b/pkg/search/query.go new file mode 100644 index 000000000..a0966ba93 --- /dev/null +++ b/pkg/search/query.go @@ -0,0 +1,111 @@ +package search + +import ( + "fmt" + "reflect" + "sort" + "strings" + + "github.com/cli/cli/v2/pkg/text" +) + +const ( + KindRepositories = "repositories" +) + +type Query struct { + Keywords []string + Kind string + Limit int + Order string + Page int + Qualifiers Qualifiers + Sort string +} + +type Qualifiers struct { + Archived *bool + Created string + Followers string + Fork string + Forks string + GoodFirstIssues string + HelpWantedIssues string + In []string + Is string + Language string + License []string + Org string + Pushed string + Size string + Stars string + Topic []string + Topics string +} + +func (q Query) String() string { + qualifiers := formatQualifiers(q.Qualifiers) + keywords := formatKeywords(q.Keywords) + all := append(keywords, qualifiers...) + return strings.Join(all, " ") +} + +func (q Qualifiers) Map() map[string][]string { + m := map[string][]string{} + v := reflect.ValueOf(q) + t := reflect.TypeOf(q) + for i := 0; i < v.NumField(); i++ { + fieldName := t.Field(i).Name + key := text.CamelToKebab(fieldName) + typ := v.FieldByName(fieldName).Kind() + value := v.FieldByName(fieldName) + switch typ { + case reflect.Ptr: + if value.IsNil() { + continue + } + v := reflect.Indirect(value) + m[key] = []string{fmt.Sprintf("%v", v)} + case reflect.Slice: + if value.IsNil() { + continue + } + s := []string{} + for i := 0; i < value.Len(); i++ { + s = append(s, fmt.Sprintf("%v", value.Index(i))) + } + m[key] = s + default: + if value.IsZero() { + continue + } + m[key] = []string{fmt.Sprintf("%v", value)} + } + } + return m +} + +func quote(s string) string { + if strings.ContainsAny(s, " \"\t\r\n") { + return fmt.Sprintf("%q", s) + } + return s +} + +func formatQualifiers(qs Qualifiers) []string { + var all []string + for k, vs := range qs.Map() { + for _, v := range vs { + all = append(all, fmt.Sprintf("%s:%s", k, quote(v))) + } + } + sort.Strings(all) + return all +} + +func formatKeywords(ks []string) []string { + for i, k := range ks { + ks[i] = quote(k) + } + return ks +} diff --git a/pkg/search/query_test.go b/pkg/search/query_test.go new file mode 100644 index 000000000..889acae76 --- /dev/null +++ b/pkg/search/query_test.go @@ -0,0 +1,135 @@ +package search + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +var trueBool = true + +func TestQueryString(t *testing.T) { + tests := []struct { + name string + query Query + out string + }{ + { + name: "converts query to string", + query: Query{ + Keywords: []string{"some", "keywords"}, + Qualifiers: Qualifiers{ + Archived: &trueBool, + Created: "created", + Followers: "1", + Fork: "true", + Forks: "2", + GoodFirstIssues: "3", + HelpWantedIssues: "4", + In: []string{"description", "readme"}, + Language: "language", + License: []string{"license"}, + Org: "org", + Pushed: "updated", + Size: "5", + Stars: "6", + Topic: []string{"topic"}, + Topics: "7", + Is: "public", + }, + }, + out: "some keywords archived:true created:created followers:1 fork:true forks:2 good-first-issues:3 help-wanted-issues:4 in:description in:readme is:public language:language license:license org:org pushed:updated size:5 stars:6 topic:topic topics:7", + }, + { + name: "quotes keywords", + query: Query{ + Keywords: []string{"quote keywords"}, + }, + out: "\"quote keywords\"", + }, + { + name: "quotes qualifiers", + query: Query{ + Qualifiers: Qualifiers{ + Topic: []string{"quote qualifier"}, + }, + }, + out: "topic:\"quote qualifier\"", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.out, tt.query.String()) + }) + } +} + +func TestQualifiersMap(t *testing.T) { + tests := []struct { + name string + qualifiers Qualifiers + out map[string][]string + }{ + { + name: "changes qualifiers to map", + qualifiers: Qualifiers{ + Archived: &trueBool, + Created: "created", + Followers: "1", + Fork: "true", + Forks: "2", + GoodFirstIssues: "3", + HelpWantedIssues: "4", + In: []string{"readme"}, + Language: "language", + License: []string{"license"}, + Org: "org", + Pushed: "updated", + Size: "5", + Stars: "6", + Topic: []string{"topic"}, + Topics: "7", + Is: "public", + }, + out: map[string][]string{ + "archived": {"true"}, + "created": {"created"}, + "followers": {"1"}, + "fork": {"true"}, + "forks": {"2"}, + "good-first-issues": {"3"}, + "help-wanted-issues": {"4"}, + "in": {"readme"}, + "is": {"public"}, + "language": {"language"}, + "license": {"license"}, + "org": {"org"}, + "pushed": {"updated"}, + "size": {"5"}, + "stars": {"6"}, + "topic": {"topic"}, + "topics": {"7"}, + }, + }, + { + name: "excludes unset qualifiers from map", + qualifiers: Qualifiers{ + Org: "org", + Pushed: "updated", + Size: "5", + Stars: "6", + }, + out: map[string][]string{ + "org": {"org"}, + "pushed": {"updated"}, + "size": {"5"}, + "stars": {"6"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.out, tt.qualifiers.Map()) + }) + } +} diff --git a/pkg/search/result.go b/pkg/search/result.go new file mode 100644 index 000000000..99b3d2142 --- /dev/null +++ b/pkg/search/result.go @@ -0,0 +1,120 @@ +package search + +import ( + "reflect" + "strings" + "time" +) + +var RepositoryFields = []string{ + "createdAt", + "defaultBranch", + "description", + "forksCount", + "fullName", + "hasDownloads", + "hasIssues", + "hasPages", + "hasProjects", + "hasWiki", + "homepage", + "id", + "isArchived", + "isDisabled", + "isFork", + "isPrivate", + "language", + "license", + "name", + "openIssuesCount", + "owner", + "pushedAt", + "size", + "stargazersCount", + "updatedAt", + "visibility", + "watchersCount", +} + +type RepositoriesResult struct { + IncompleteResults bool `json:"incomplete_results"` + Items []Repository `json:"items"` + Total int `json:"total_count"` +} + +type Repository struct { + CreatedAt time.Time `json:"created_at"` + DefaultBranch string `json:"default_branch"` + Description string `json:"description"` + ForksCount int `json:"forks_count"` + FullName string `json:"full_name"` + HasDownloads bool `json:"has_downloads"` + HasIssues bool `json:"has_issues"` + HasPages bool `json:"has_pages"` + HasProjects bool `json:"has_projects"` + HasWiki bool `json:"has_wiki"` + Homepage string `json:"homepage"` + ID int64 `json:"id"` + IsArchived bool `json:"archived"` + IsDisabled bool `json:"disabled"` + IsFork bool `json:"fork"` + IsPrivate bool `json:"private"` + Language string `json:"language"` + License License `json:"license"` + MasterBranch string `json:"master_branch"` + Name string `json:"name"` + OpenIssuesCount int `json:"open_issues_count"` + Owner User `json:"owner"` + PushedAt time.Time `json:"pushed_at"` + Size int `json:"size"` + StargazersCount int `json:"stargazers_count"` + UpdatedAt time.Time `json:"updated_at"` + Visibility string `json:"visibility"` + WatchersCount int `json:"watchers_count"` +} + +type License struct { + HTMLURL string `json:"html_url"` + Key string `json:"key"` + Name string `json:"name"` + URL string `json:"url"` +} + +type User struct { + GravatarID string `json:"gravatar_id"` + ID int64 `json:"id"` + Login string `json:"login"` + SiteAdmin bool `json:"site_admin"` + Type string `json:"type"` +} + +func (repo Repository) ExportData(fields []string) map[string]interface{} { + v := reflect.ValueOf(repo) + data := map[string]interface{}{} + for _, f := range fields { + switch f { + case "license": + data[f] = map[string]interface{}{ + "key": repo.License.Key, + "name": repo.License.Name, + "url": repo.License.URL, + } + case "owner": + data[f] = map[string]interface{}{ + "id": repo.Owner.ID, + "login": repo.Owner.Login, + "type": repo.Owner.Type, + } + default: + sf := fieldByName(v, f) + data[f] = sf.Interface() + } + } + return data +} + +func fieldByName(v reflect.Value, field string) reflect.Value { + return v.FieldByNameFunc(func(s string) bool { + return strings.EqualFold(field, s) + }) +} diff --git a/pkg/search/result_test.go b/pkg/search/result_test.go new file mode 100644 index 000000000..185cc4e36 --- /dev/null +++ b/pkg/search/result_test.go @@ -0,0 +1,46 @@ +package search + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRepositoryExportData(t *testing.T) { + var createdAt = time.Date(2021, 2, 28, 12, 30, 0, 0, time.UTC) + tests := []struct { + name string + fields []string + repo Repository + output string + }{ + { + name: "exports requested fields", + fields: []string{"createdAt", "description", "fullName", "isArchived", "isFork", "isPrivate", "pushedAt"}, + repo: Repository{ + CreatedAt: createdAt, + Description: "description", + FullName: "cli/cli", + IsArchived: true, + IsFork: false, + IsPrivate: false, + PushedAt: createdAt, + }, + output: `{"createdAt":"2021-02-28T12:30:00Z","description":"description","fullName":"cli/cli","isArchived":true,"isFork":false,"isPrivate":false,"pushedAt":"2021-02-28T12:30:00Z"}`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exported := tt.repo.ExportData(tt.fields) + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + require.NoError(t, enc.Encode(exported)) + assert.Equal(t, tt.output, strings.TrimSpace(buf.String())) + }) + } +} diff --git a/pkg/search/searcher.go b/pkg/search/searcher.go new file mode 100644 index 000000000..39d2c09c5 --- /dev/null +++ b/pkg/search/searcher.go @@ -0,0 +1,184 @@ +package search + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/cli/cli/v2/internal/ghinstance" +) + +const ( + maxPerPage = 100 + orderKey = "order" + sortKey = "sort" +) + +var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) +var pageRE = regexp.MustCompile(`(\?|&)page=(\d*)`) +var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`) + +//go:generate moq -rm -out searcher_mock.go . Searcher +type Searcher interface { + Repositories(Query) (RepositoriesResult, error) + URL(Query) string +} + +type searcher struct { + client *http.Client + host string +} + +type httpError struct { + Errors []httpErrorItem + Message string + RequestURL *url.URL + StatusCode int +} + +type httpErrorItem struct { + Code string + Field string + Message string + Resource string +} + +func NewSearcher(client *http.Client, host string) Searcher { + return &searcher{ + client: client, + host: host, + } +} + +func (s searcher) Repositories(query Query) (RepositoriesResult, error) { + result := RepositoriesResult{} + toRetrieve := query.Limit + var resp *http.Response + var err error + for toRetrieve > 0 { + query.Limit = min(toRetrieve, maxPerPage) + query.Page = nextPage(resp) + if query.Page == 0 { + break + } + page := RepositoriesResult{} + resp, err = s.search(query, &page) + if err != nil { + return result, err + } + result.IncompleteResults = page.IncompleteResults + result.Total = page.Total + result.Items = append(result.Items, page.Items...) + toRetrieve = toRetrieve - len(page.Items) + } + return result, nil +} + +func (s searcher) search(query Query, result interface{}) (*http.Response, error) { + path := fmt.Sprintf("%ssearch/%s", ghinstance.RESTPrefix(s.host), query.Kind) + qs := url.Values{} + qs.Set("page", strconv.Itoa(query.Page)) + qs.Set("per_page", strconv.Itoa(query.Limit)) + qs.Set("q", query.String()) + if query.Order != "" { + qs.Set(orderKey, query.Order) + } + if query.Sort != "" { + qs.Set(sortKey, query.Sort) + } + url := fmt.Sprintf("%s?%s", path, qs.Encode()) + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + req.Header.Set("Accept", "application/vnd.github.v3+json") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !success { + return resp, handleHTTPError(resp) + } + decoder := json.NewDecoder(resp.Body) + err = decoder.Decode(result) + if err != nil { + return resp, err + } + return resp, nil +} + +func (s searcher) URL(query Query) string { + path := fmt.Sprintf("https://%s/search", s.host) + qs := url.Values{} + qs.Set("type", query.Kind) + qs.Set("q", query.String()) + if query.Order != "" { + qs.Set(orderKey, query.Order) + } + if query.Sort != "" { + qs.Set(sortKey, query.Sort) + } + url := fmt.Sprintf("%s?%s", path, qs.Encode()) + return url +} + +func (err httpError) Error() string { + if err.StatusCode != 422 || len(err.Errors) == 0 { + return fmt.Sprintf("HTTP %d: %s (%s)", err.StatusCode, err.Message, err.RequestURL) + } + query := strings.TrimSpace(err.RequestURL.Query().Get("q")) + return fmt.Sprintf("Invalid search query %q.\n%s", query, err.Errors[0].Message) +} + +func handleHTTPError(resp *http.Response) error { + httpError := httpError{ + RequestURL: resp.Request.URL, + StatusCode: resp.StatusCode, + } + if !jsonTypeRE.MatchString(resp.Header.Get("Content-Type")) { + httpError.Message = resp.Status + return httpError + } + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + if err := json.Unmarshal(body, &httpError); err != nil { + return err + } + return httpError +} + +func nextPage(resp *http.Response) (page int) { + if resp == nil { + return 1 + } + for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) { + if !(len(m) > 2 && m[2] == "next") { + continue + } + p := pageRE.FindStringSubmatch(m[1]) + if len(p) == 3 { + i, err := strconv.Atoi(p[2]) + if err == nil { + return i + } + } + } + return 0 +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/search/searcher_mock.go b/pkg/search/searcher_mock.go new file mode 100644 index 000000000..9d584867b --- /dev/null +++ b/pkg/search/searcher_mock.go @@ -0,0 +1,116 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package search + +import ( + "sync" +) + +// Ensure, that SearcherMock does implement Searcher. +// If this is not the case, regenerate this file with moq. +var _ Searcher = &SearcherMock{} + +// SearcherMock is a mock implementation of Searcher. +// +// func TestSomethingThatUsesSearcher(t *testing.T) { +// +// // make and configure a mocked Searcher +// mockedSearcher := &SearcherMock{ +// RepositoriesFunc: func(query Query) (RepositoriesResult, error) { +// panic("mock out the Repositories method") +// }, +// URLFunc: func(query Query) string { +// panic("mock out the URL method") +// }, +// } +// +// // use mockedSearcher in code that requires Searcher +// // and then make assertions. +// +// } +type SearcherMock struct { + // RepositoriesFunc mocks the Repositories method. + RepositoriesFunc func(query Query) (RepositoriesResult, error) + + // URLFunc mocks the URL method. + URLFunc func(query Query) string + + // calls tracks calls to the methods. + calls struct { + // Repositories holds details about calls to the Repositories method. + Repositories []struct { + // Query is the query argument value. + Query Query + } + // URL holds details about calls to the URL method. + URL []struct { + // Query is the query argument value. + Query Query + } + } + lockRepositories sync.RWMutex + lockURL sync.RWMutex +} + +// Repositories calls RepositoriesFunc. +func (mock *SearcherMock) Repositories(query Query) (RepositoriesResult, error) { + if mock.RepositoriesFunc == nil { + panic("SearcherMock.RepositoriesFunc: method is nil but Searcher.Repositories was just called") + } + callInfo := struct { + Query Query + }{ + Query: query, + } + mock.lockRepositories.Lock() + mock.calls.Repositories = append(mock.calls.Repositories, callInfo) + mock.lockRepositories.Unlock() + return mock.RepositoriesFunc(query) +} + +// RepositoriesCalls gets all the calls that were made to Repositories. +// Check the length with: +// len(mockedSearcher.RepositoriesCalls()) +func (mock *SearcherMock) RepositoriesCalls() []struct { + Query Query +} { + var calls []struct { + Query Query + } + mock.lockRepositories.RLock() + calls = mock.calls.Repositories + mock.lockRepositories.RUnlock() + return calls +} + +// URL calls URLFunc. +func (mock *SearcherMock) URL(query Query) string { + if mock.URLFunc == nil { + panic("SearcherMock.URLFunc: method is nil but Searcher.URL was just called") + } + callInfo := struct { + Query Query + }{ + Query: query, + } + mock.lockURL.Lock() + mock.calls.URL = append(mock.calls.URL, callInfo) + mock.lockURL.Unlock() + return mock.URLFunc(query) +} + +// URLCalls gets all the calls that were made to URL. +// Check the length with: +// len(mockedSearcher.URLCalls()) +func (mock *SearcherMock) URLCalls() []struct { + Query Query +} { + var calls []struct { + Query Query + } + mock.lockURL.RLock() + calls = mock.calls.URL + mock.lockURL.RUnlock() + return calls +} diff --git a/pkg/search/searcher_test.go b/pkg/search/searcher_test.go new file mode 100644 index 000000000..9aadc32f1 --- /dev/null +++ b/pkg/search/searcher_test.go @@ -0,0 +1,198 @@ +package search + +import ( + "net/http" + "net/url" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/v2/pkg/httpmock" + "github.com/stretchr/testify/assert" +) + +var query = Query{ + Keywords: []string{"keyword"}, + Kind: "repositories", + Limit: 30, + Order: "stars", + Sort: "desc", + Qualifiers: Qualifiers{ + Stars: ">=5", + Topic: []string{"topic"}, + }, +} + +func TestSearcherRepositories(t *testing.T) { + values := url.Values{ + "page": []string{"1"}, + "per_page": []string{"30"}, + "order": []string{"stars"}, + "sort": []string{"desc"}, + "q": []string{"keyword stars:>=5 topic:topic"}, + } + + tests := []struct { + name string + host string + query Query + result RepositoriesResult + wantErr bool + errMsg string + httpStubs func(*httpmock.Registry) + }{ + { + name: "searches repositories", + query: query, + result: RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "test"}}, + Total: 1, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.QueryMatcher("GET", "search/repositories", values), + httpmock.JSONResponse(RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "test"}}, + Total: 1, + }), + ) + }, + }, + { + name: "searches repositories for enterprise host", + host: "enterprise.com", + query: query, + result: RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "test"}}, + Total: 1, + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.QueryMatcher("GET", "api/v3/search/repositories", values), + httpmock.JSONResponse(RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "test"}}, + Total: 1, + }), + ) + }, + }, + { + name: "paginates results", + query: query, + result: RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "test"}, {Name: "cli"}}, + Total: 2, + }, + httpStubs: func(reg *httpmock.Registry) { + firstReq := httpmock.QueryMatcher("GET", "search/repositories", values) + firstRes := httpmock.JSONResponse(RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "test"}}, + Total: 2, + }, + ) + firstRes = httpmock.WithHeader(firstRes, "Link", `; rel="next"`) + secondReq := httpmock.QueryMatcher("GET", "search/repositories", url.Values{ + "page": []string{"2"}, + "per_page": []string{"29"}, + "order": []string{"stars"}, + "sort": []string{"desc"}, + "q": []string{"keyword stars:>=5 topic:topic"}, + }, + ) + secondRes := httpmock.JSONResponse(RepositoriesResult{ + IncompleteResults: false, + Items: []Repository{{Name: "cli"}}, + Total: 2, + }, + ) + reg.Register(firstReq, firstRes) + reg.Register(secondReq, secondRes) + }, + }, + { + name: "handles search errors", + query: query, + wantErr: true, + errMsg: heredoc.Doc(` + Invalid search query "keyword stars:>=5 topic:topic". + "blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`), + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.QueryMatcher("GET", "search/repositories", values), + httpmock.WithHeader( + httpmock.StatusStringResponse(422, + `{ + "message":"Validation Failed", + "errors":[ + { + "message":"\"blah\" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.", + "resource":"Search", + "field":"q", + "code":"invalid" + } + ], + "documentation_url":"https://docs.github.com/v3/search/" + }`, + ), "Content-Type", "application/json"), + ) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reg := &httpmock.Registry{} + defer reg.Verify(t) + if tt.httpStubs != nil { + tt.httpStubs(reg) + } + client := &http.Client{Transport: reg} + if tt.host == "" { + tt.host = "github.com" + } + searcher := NewSearcher(client, tt.host) + result, err := searcher.Repositories(tt.query) + if tt.wantErr { + assert.EqualError(t, err, tt.errMsg) + return + } + assert.NoError(t, err) + assert.Equal(t, tt.result, result) + }) + } +} + +func TestSearcherURL(t *testing.T) { + tests := []struct { + name string + host string + query Query + url string + }{ + { + name: "outputs encoded query url", + query: query, + url: "https://github.com/search?order=stars&q=keyword+stars%3A%3E%3D5+topic%3Atopic&sort=desc&type=repositories", + }, + { + name: "supports enterprise hosts", + host: "enterprise.com", + query: query, + url: "https://enterprise.com/search?order=stars&q=keyword+stars%3A%3E%3D5+topic%3Atopic&sort=desc&type=repositories", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.host == "" { + tt.host = "github.com" + } + searcher := NewSearcher(nil, tt.host) + assert.Equal(t, tt.url, searcher.URL(tt.query)) + }) + } +} diff --git a/pkg/text/convert.go b/pkg/text/convert.go new file mode 100644 index 000000000..c5d2f401d --- /dev/null +++ b/pkg/text/convert.go @@ -0,0 +1,29 @@ +package text + +import "unicode" + +// Copied from: https://github.com/asaskevich/govalidator +func CamelToKebab(str string) string { + var output []rune + var segment []rune + for _, r := range str { + if !unicode.IsLower(r) && string(r) != "-" && !unicode.IsNumber(r) { + output = addSegment(output, segment) + segment = nil + } + segment = append(segment, unicode.ToLower(r)) + } + output = addSegment(output, segment) + return string(output) +} + +func addSegment(inrune, segment []rune) []rune { + if len(segment) == 0 { + return inrune + } + if len(inrune) != 0 { + inrune = append(inrune, '-') + } + inrune = append(inrune, segment...) + return inrune +} diff --git a/pkg/text/convert_test.go b/pkg/text/convert_test.go new file mode 100644 index 000000000..5321fbf10 --- /dev/null +++ b/pkg/text/convert_test.go @@ -0,0 +1,61 @@ +package text + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCamelToKebab(t *testing.T) { + tests := []struct { + name string + in string + out string + }{ + { + name: "single lowercase word", + in: "test", + out: "test", + }, + { + name: "multiple mixed words", + in: "testTestTest", + out: "test-test-test", + }, + { + name: "multiple uppercase words", + in: "TestTest", + out: "test-test", + }, + { + name: "multiple lowercase words", + in: "testtest", + out: "testtest", + }, + { + name: "multiple mixed words with number", + in: "test2Test", + out: "test2-test", + }, + { + name: "multiple lowercase words with number", + in: "test2test", + out: "test2test", + }, + { + name: "multiple lowercase words with dash", + in: "test-test", + out: "test-test", + }, + { + name: "multiple uppercase words with dash", + in: "Test-Test", + out: "test--test", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.out, CamelToKebab(tt.in)) + }) + } +}