Merge remote-tracking branch 'origin/trunk' into add-more-author-infomation

This commit is contained in:
vilmibm 2022-12-07 11:54:55 -08:00
commit 84a15d0943
549 changed files with 42643 additions and 16709 deletions

View file

@ -0,0 +1,24 @@
{
"image": "mcr.microsoft.com/devcontainers/go:1.18",
"features": {
"ghcr.io/devcontainers/features/sshd:1": {}
},
"remoteUser": "vscode",
"customizations": {
"vscode": {
"extensions": [
"golang.go"
],
"settings": {
"go.toolsManagement.checkForUpdates": "local",
"go.useLanguageServer": true,
"go.gopath": "/go"
}
}
},
"runArgs": [
"--cap-add=SYS_PTRACE",
"--security-opt",
"seccomp=unconfined"
]
}

13
.github/SECURITY.md vendored
View file

@ -1,3 +1,14 @@
If you discover a security issue in this repository, please submit it through the [GitHub Security Bug Bounty](https://hackerone.com/github).
GitHub takes the security of our software products and services seriously, including the open source code repositories managed through our GitHub organizations, such as [cli](https://github.com/cli).
If you believe you have found a security vulnerability in GitHub CLI, you can report it to us in one of two ways:
* Report it to this repository directly using [private vulnerability reporting][]. Such reports are not eligible for a bounty reward.
* Submit the report through [HackerOne][] to be eligible for a bounty reward.
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
Thanks for helping make GitHub safe for everyone.
[private vulnerability reporting]: https://github.com/cli/cli/security/advisories
[HackerOne]: https://hackerone.com/github

View file

@ -10,19 +10,24 @@ 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
steps:
- name: Check out code
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
uses: github/codeql-action/init@v2
with:
languages: go
queries: security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
uses: github/codeql-action/analyze@v2

View file

@ -1,5 +1,9 @@
name: Tests
on: [push, pull_request]
permissions:
contents: read
jobs:
build:
strategy:
@ -9,22 +13,21 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Set up Go 1.16
uses: actions/setup-go@v2
- name: Set up Go 1.18
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18
- name: Check out code
uses: actions/checkout@v2
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

View file

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

View file

@ -11,25 +11,36 @@ on:
- go.mod
- go.sum
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.16
uses: actions/setup-go@v2
- name: Set up Go 1.18
uses: actions/setup-go@v3
with:
go-version: 1.16
go-version: 1.18
- name: Check out code
uses: actions/checkout@v2
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.46.0
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/

View file

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

View file

@ -5,16 +5,22 @@ on:
tags:
- "v*"
permissions:
contents: write # publishing releases
repository-projects: write # move cards between columns
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up Go 1.16
uses: actions/setup-go@v2
uses: actions/checkout@v3
with:
go-version: 1.16
fetch-depth: 0
- name: Set up Go 1.18
uses: actions/setup-go@v3
with:
go-version: 1.18
- name: Generate changelog
id: changelog
run: |
@ -25,16 +31,26 @@ jobs:
-q .body > CHANGELOG.md
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Install osslsigncode
run: sudo apt-get install -y osslsigncode
- name: Obtain signing cert
run: |
cert="$(mktemp -t cert.XXX)"
base64 -d <<<"$CERT_CONTENTS" > "$cert"
echo "CERT_FILE=$cert" >> $GITHUB_ENV
env:
CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
uses: goreleaser/goreleaser-action@v3
with:
version: v0.174.1
version: v1.12.3
args: release --release-notes=CHANGELOG.md
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
GORELEASER_CURRENT_TAG: ${{steps.changelog.outputs.tag-name}}
CERT_PASSWORD: ${{secrets.WINDOWS_CERT_PASSWORD}}
- name: Checkout documentation site
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
repository: github/cli.github.com
path: site
@ -61,16 +77,15 @@ jobs:
api-write --silent projects/columns/cards/$card/moves -f position=top -F column_id=$DONE_COLUMN
done
echo "moved ${#cards[@]} cards to the Done column"
- name: Install packaging dependencies
run: sudo apt-get install -y rpm reprepro
- name: Set up GPG
run: |
gpg --import --no-tty --batch --yes < script/pubkey.asc
echo "${{secrets.GPG_PUBKEY}}" | base64 -d | gpg --import --no-tty --batch --yes
echo "${{secrets.GPG_KEY}}" | base64 -d | gpg --import --no-tty --batch --yes
echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf
gpg-connect-agent RELOADAGENT /bye
echo "${{secrets.GPG_PASSPHRASE}}" | /usr/lib/gnupg2/gpg-preset-passphrase --preset 867DAD5051270B843EF54F6186FA10E3A1D22DC5
echo "${{secrets.GPG_PASSPHRASE}}" | /usr/lib/gnupg2/gpg-preset-passphrase --preset "${{secrets.GPG_KEYGRIP}}"
- name: Sign RPMs
run: |
cp script/rpmmacros ~/.rpmmacros
@ -86,6 +101,9 @@ jobs:
popd
- name: Run reprepro
env:
# We are no longer adding to the distribution list.
# All apt distributions should use "stable" according to our install documentation.
# In the future we will remove legacy distributions listed here.
RELEASES: "cosmic eoan disco groovy focal stable oldstable testing sid unstable buster bullseye stretch jessie bionic trusty precise xenial hirsute impish kali-rolling"
run: |
mkdir -p upload
@ -119,7 +137,7 @@ jobs:
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3
- name: Download gh.exe
id: download_exe
shell: bash
@ -129,34 +147,33 @@ jobs:
unzip -o *.zip && rm -v *.zip
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Install go-msi
run: choco install -y "go-msi"
- name: Prepare PATH
shell: bash
run: |
echo "$WIX\\bin" >> $GITHUB_PATH
echo "C:\\Program Files\\go-msi" >> $GITHUB_PATH
id: setupmsbuild
uses: microsoft/setup-msbuild@v1.1.3
- name: Build MSI
id: buildmsi
shell: bash
env:
ZIP_FILE: ${{ steps.download_exe.outputs.zip }}
MSBUILD_PATH: ${{ steps.setupmsbuild.outputs.msbuildPath }}
run: |
mkdir -p build
msi="$(basename "$ZIP_FILE" ".zip").msi"
printf "::set-output name=msi::%s\n" "$msi"
go-msi make --msi "$PWD/$msi" --out "$PWD/build" --version "${GITHUB_REF#refs/tags/}"
name="$(basename "$ZIP_FILE" ".zip")"
version="$(echo -e ${GITHUB_REF#refs/tags/v} | sed s/-.*$//)"
"${MSBUILD_PATH}\MSBuild.exe" ./build/windows/gh.wixproj -p:SourceDir="$PWD" -p:OutputPath="$PWD" -p:OutputName="$name" -p:ProductVersion="$version"
- name: Obtain signing cert
id: obtain_cert
shell: bash
run: |
base64 -d <<<"$CERT_CONTENTS" > ./cert.pfx
printf "::set-output name=cert-file::%s\n" ".\\cert.pfx"
env:
DESKTOP_CERT_TOKEN: ${{ secrets.DESKTOP_CERT_TOKEN }}
run: .\script\setup-windows-certificate.ps1
CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }}
- name: Sign MSI
env:
CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }}
EXE_FILE: ${{ steps.buildmsi.outputs.msi }}
GITHUB_CERT_PASSWORD: ${{ secrets.GITHUB_CERT_PASSWORD }}
run: .\script\sign.ps1 -Certificate $env:CERT_FILE -Executable $env:EXE_FILE
CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
run: .\script\signtool sign /d "GitHub CLI" /f $env:CERT_FILE /p $env:CERT_PASSWORD /fd sha256 /tr http://timestamp.digicert.com /v $env:EXE_FILE
- name: Upload MSI
shell: bash
run: |
@ -173,14 +190,14 @@ jobs:
DISCUSSION_CATEGORY: General
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Bump homebrew-core formula
uses: mislav/bump-homebrew-formula-action@v1
uses: mislav/bump-homebrew-formula-action@v2
if: "!contains(github.ref, '-')" # skip prereleases
with:
formula-name: gh
env:
COMMITTER_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }}
- name: Checkout scoop bucket
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
repository: cli/scoop-gh
path: scoop-gh
@ -203,18 +220,3 @@ jobs:
GIT_AUTHOR_NAME: cli automation
GIT_COMMITTER_EMAIL: noreply@github.com
GIT_AUTHOR_EMAIL: noreply@github.com
- name: Bump Winget manifest
shell: pwsh
env:
WINGETCREATE_VERSION: v0.2.0.29-preview
GITHUB_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }}
run: |
$tagname = $env:GITHUB_REF.Replace("refs/tags/", "")
$version = $tagname.Replace("v", "")
$url = "https://github.com/cli/cli/releases/download/${tagname}/gh_${version}_windows_amd64.msi"
iwr https://github.com/microsoft/winget-create/releases/download/${env:WINGETCREATE_VERSION}/wingetcreate.exe -OutFile wingetcreate.exe
.\wingetcreate.exe update GitHub.cli --url $url --version $version
if ($version -notmatch "-") {
.\wingetcreate.exe submit .\manifests\g\GitHub\cli\${version}\ --token $env:GITHUB_TOKEN
}

View file

@ -31,7 +31,10 @@ builds:
- <<: *build_defaults
id: windows
goos: [windows]
goarch: [386, amd64]
goarch: [386, amd64, arm64]
hooks:
post:
- ./script/sign-windows-executable.sh '{{ .Path }}'
archives:
- id: nix
@ -57,7 +60,7 @@ nfpms:
- license: MIT
maintainer: GitHub
homepage: https://github.com/cli/cli
bindir: /usr/bin
bindir: /usr
dependencies:
- git
description: GitHubs official command line tool.

View file

@ -1,5 +0,0 @@
{
"search.exclude": {
"vendor/**": true
}
}

View file

@ -8,7 +8,7 @@ GitHub CLI is available for repositories hosted on GitHub.com and GitHub Enterpr
## Documentation
[See the manual][manual] for setup and usage instructions.
For [installation options see below](#installation), for usage instructions [see the manual][manual].
## Contributing
@ -49,9 +49,13 @@ Additional Conda installation options available on the [gh-feedstock page](https
### Linux & BSD
`gh` is available via [Homebrew](#homebrew), [Conda](#conda), [Spack](#spack), and as downloadable binaries from the [releases page][].
`gh` is available via:
- [our Debian and RPM repositories](./docs/install_linux.md);
- community-maintained repositories in various Linux distros;
- OS-agnostic package managers such as [Homebrew](#homebrew), [Conda](#conda), and [Spack](#spack); and
- our [releases page][] as precompiled binaries.
For instructions on specific distributions and package managers, see [Linux & BSD installation](./docs/install_linux.md).
For more information, see [Linux & BSD installation](./docs/install_linux.md).
### Windows
@ -79,6 +83,16 @@ For instructions on specific distributions and package managers, see [Linux & BS
MSI installers are available for download on the [releases page][].
### Codespaces
To add GitHub CLI to your codespace, add the following to your [devcontainer file](https://docs.github.com/en/codespaces/setting-up-your-project-for-codespaces/adding-features-to-a-devcontainer-file):
```json
"features": {
"github-cli": "latest"
}
```
### GitHub Actions
GitHub CLI comes pre-installed in all [GitHub-Hosted Runners](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners).

View file

@ -1,179 +0,0 @@
package api
import (
"bufio"
"bytes"
"crypto/sha256"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
func NewCachedClient(httpClient *http.Client, cacheTTL time.Duration) *http.Client {
cacheDir := filepath.Join(os.TempDir(), "gh-cli-cache")
return &http.Client{
Transport: CacheResponse(cacheTTL, cacheDir)(httpClient.Transport),
}
}
func isCacheableRequest(req *http.Request) bool {
if strings.EqualFold(req.Method, "GET") || strings.EqualFold(req.Method, "HEAD") {
return true
}
if strings.EqualFold(req.Method, "POST") && (req.URL.Path == "/graphql" || req.URL.Path == "/api/graphql") {
return true
}
return false
}
func isCacheableResponse(res *http.Response) bool {
return res.StatusCode < 500 && res.StatusCode != 403
}
// CacheResponse produces a RoundTripper that caches HTTP responses to disk for a specified amount of time
func CacheResponse(ttl time.Duration, dir string) ClientOption {
fs := fileStorage{
dir: dir,
ttl: ttl,
mu: &sync.RWMutex{},
}
return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
if !isCacheableRequest(req) {
return tr.RoundTrip(req)
}
key, keyErr := cacheKey(req)
if keyErr == nil {
if res, err := fs.read(key); err == nil {
res.Request = req
return res, nil
}
}
res, err := tr.RoundTrip(req)
if err == nil && keyErr == nil && isCacheableResponse(res) {
_ = fs.store(key, res)
}
return res, err
}}
}
}
func copyStream(r io.ReadCloser) (io.ReadCloser, io.ReadCloser) {
b := &bytes.Buffer{}
nr := io.TeeReader(r, b)
return ioutil.NopCloser(b), &readCloser{
Reader: nr,
Closer: r,
}
}
type readCloser struct {
io.Reader
io.Closer
}
func cacheKey(req *http.Request) (string, error) {
h := sha256.New()
fmt.Fprintf(h, "%s:", req.Method)
fmt.Fprintf(h, "%s:", req.URL.String())
fmt.Fprintf(h, "%s:", req.Header.Get("Accept"))
fmt.Fprintf(h, "%s:", req.Header.Get("Authorization"))
if req.Body != nil {
var bodyCopy io.ReadCloser
req.Body, bodyCopy = copyStream(req.Body)
defer bodyCopy.Close()
if _, err := io.Copy(h, bodyCopy); err != nil {
return "", err
}
}
digest := h.Sum(nil)
return fmt.Sprintf("%x", digest), nil
}
type fileStorage struct {
dir string
ttl time.Duration
mu *sync.RWMutex
}
func (fs *fileStorage) filePath(key string) string {
if len(key) >= 6 {
return filepath.Join(fs.dir, key[0:2], key[2:4], key[4:])
}
return filepath.Join(fs.dir, key)
}
func (fs *fileStorage) read(key string) (*http.Response, error) {
cacheFile := fs.filePath(key)
fs.mu.RLock()
defer fs.mu.RUnlock()
f, err := os.Open(cacheFile)
if err != nil {
return nil, err
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return nil, err
}
age := time.Since(stat.ModTime())
if age > fs.ttl {
return nil, errors.New("cache expired")
}
body := &bytes.Buffer{}
_, err = io.Copy(body, f)
if err != nil {
return nil, err
}
res, err := http.ReadResponse(bufio.NewReader(body), nil)
return res, err
}
func (fs *fileStorage) store(key string, res *http.Response) error {
cacheFile := fs.filePath(key)
fs.mu.Lock()
defer fs.mu.Unlock()
err := os.MkdirAll(filepath.Dir(cacheFile), 0755)
if err != nil {
return err
}
f, err := os.OpenFile(cacheFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer f.Close()
var origBody io.ReadCloser
if res.Body != nil {
origBody, res.Body = copyStream(res.Body)
defer res.Body.Close()
}
err = res.Write(f)
if origBody != nil {
res.Body = origBody
}
return err
}

View file

@ -1,89 +0,0 @@
package api
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"net/http"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_CacheResponse(t *testing.T) {
counter := 0
fakeHTTP := funcTripper{
roundTrip: func(req *http.Request) (*http.Response, error) {
counter += 1
body := fmt.Sprintf("%d: %s %s", counter, req.Method, req.URL.String())
status := 200
if req.URL.Path == "/error" {
status = 500
}
return &http.Response{
StatusCode: status,
Body: ioutil.NopCloser(bytes.NewBufferString(body)),
}, nil
},
}
cacheDir := filepath.Join(t.TempDir(), "gh-cli-cache")
httpClient := NewHTTPClient(ReplaceTripper(fakeHTTP), CacheResponse(time.Minute, cacheDir))
do := func(method, url string, body io.Reader) (string, error) {
req, err := http.NewRequest(method, url, body)
if err != nil {
return "", err
}
res, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
resBody, err := ioutil.ReadAll(res.Body)
if err != nil {
err = fmt.Errorf("ReadAll: %w", err)
}
return string(resBody), err
}
var res string
var err error
res, err = do("GET", "http://example.com/path", nil)
require.NoError(t, err)
assert.Equal(t, "1: GET http://example.com/path", res)
res, err = do("GET", "http://example.com/path", nil)
require.NoError(t, err)
assert.Equal(t, "1: GET http://example.com/path", res)
res, err = do("GET", "http://example.com/path2", nil)
require.NoError(t, err)
assert.Equal(t, "2: GET http://example.com/path2", res)
res, err = do("POST", "http://example.com/path2", nil)
require.NoError(t, err)
assert.Equal(t, "3: POST http://example.com/path2", res)
res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello`))
require.NoError(t, err)
assert.Equal(t, "4: POST http://example.com/graphql", res)
res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello`))
require.NoError(t, err)
assert.Equal(t, "4: POST http://example.com/graphql", res)
res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello2`))
require.NoError(t, err)
assert.Equal(t, "5: POST http://example.com/graphql", res)
res, err = do("GET", "http://example.com/error", nil)
require.NoError(t, err)
assert.Equal(t, "6: GET http://example.com/error", res)
res, err = do("GET", "http://example.com/error", nil)
require.NoError(t, err)
assert.Equal(t, "7: GET http://example.com/error", res)
}

View file

@ -1,112 +1,35 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/cli/cli/v2/internal/ghinstance"
graphql "github.com/cli/shurcooL-graphql"
"github.com/henvic/httpretty"
"github.com/cli/go-gh"
ghAPI "github.com/cli/go-gh/pkg/api"
)
// ClientOption represents an argument to NewClient
type ClientOption = func(http.RoundTripper) http.RoundTripper
const (
accept = "Accept"
authorization = "Authorization"
cacheTTL = "X-GH-CACHE-TTL"
graphqlFeatures = "GraphQL-Features"
features = "merge_queue"
userAgent = "User-Agent"
)
// NewHTTPClient initializes an http.Client
func NewHTTPClient(opts ...ClientOption) *http.Client {
tr := http.DefaultTransport
for _, opt := range opts {
tr = opt(tr)
}
return &http.Client{Transport: tr}
}
var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
// NewClient initializes a Client
func NewClient(opts ...ClientOption) *Client {
client := &Client{http: NewHTTPClient(opts...)}
return client
}
// NewClientFromHTTP takes in an http.Client instance
func NewClientFromHTTP(httpClient *http.Client) *Client {
client := &Client{http: httpClient}
return client
}
// AddHeader turns a RoundTripper into one that adds a request header
func AddHeader(name, value string) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
if req.Header.Get(name) == "" {
req.Header.Add(name, value)
}
return tr.RoundTrip(req)
}}
}
}
// AddHeaderFunc is an AddHeader that gets the string value from a function
func AddHeaderFunc(name string, getValue func(*http.Request) (string, error)) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
if req.Header.Get(name) != "" {
return tr.RoundTrip(req)
}
value, err := getValue(req)
if err != nil {
return nil, err
}
if value != "" {
req.Header.Add(name, value)
}
return tr.RoundTrip(req)
}}
}
}
// VerboseLog enables request/response logging within a RoundTripper
func VerboseLog(out io.Writer, logTraffic bool, colorize bool) ClientOption {
logger := &httpretty.Logger{
Time: true,
TLS: false,
Colors: colorize,
RequestHeader: logTraffic,
RequestBody: logTraffic,
ResponseHeader: logTraffic,
ResponseBody: logTraffic,
Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}},
MaxResponseBody: 10000,
}
logger.SetOutput(out)
logger.SetBodyFilter(func(h http.Header) (skip bool, err error) {
return !inspectableMIMEType(h.Get("Content-Type")), nil
})
return logger.RoundTripper
}
// ReplaceTripper substitutes the underlying RoundTripper with a custom one
func ReplaceTripper(tr http.RoundTripper) ClientOption {
return func(http.RoundTripper) http.RoundTripper {
return tr
}
}
type funcTripper struct {
roundTrip func(*http.Request) (*http.Response, error)
}
func (tr funcTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return tr.roundTrip(req)
}
// Client facilitates making HTTP requests to the GitHub API
type Client struct {
http *http.Client
}
@ -115,103 +38,165 @@ func (c *Client) HTTP() *http.Client {
return c.http
}
type graphQLResponse struct {
Data interface{}
Errors []GraphQLError
}
// GraphQLError is a single error returned in a GraphQL response
type GraphQLError struct {
Type string
Message string
Path []interface{} // mixed strings and numbers
ghAPI.GQLError
}
func (ge GraphQLError) PathString() string {
var res strings.Builder
for i, v := range ge.Path {
if i > 0 {
res.WriteRune('.')
}
fmt.Fprintf(&res, "%v", v)
}
return res.String()
}
// GraphQLErrorResponse contains errors returned in a GraphQL response
type GraphQLErrorResponse struct {
Errors []GraphQLError
}
func (gr GraphQLErrorResponse) Error() string {
errorMessages := make([]string, 0, len(gr.Errors))
for _, e := range gr.Errors {
msg := e.Message
if p := e.PathString(); p != "" {
msg = fmt.Sprintf("%s (%s)", msg, p)
}
errorMessages = append(errorMessages, msg)
}
return fmt.Sprintf("GraphQL: %s", strings.Join(errorMessages, ", "))
}
// Match checks if this error is only about a specific type on a specific path. If the path argument ends
// with a ".", it will match all its subpaths as well.
func (gr GraphQLErrorResponse) Match(expectType, expectPath string) bool {
for _, e := range gr.Errors {
if e.Type != expectType || !matchPath(e.PathString(), expectPath) {
return false
}
}
return true
}
func matchPath(p, expect string) bool {
if strings.HasSuffix(expect, ".") {
return strings.HasPrefix(p, expect) || p == strings.TrimSuffix(expect, ".")
}
return p == expect
}
// HTTPError is an error returned by a failed API call
type HTTPError struct {
StatusCode int
RequestURL *url.URL
Message string
Errors []HTTPErrorItem
ghAPI.HTTPError
scopesSuggestion string
}
type HTTPErrorItem struct {
Message string
Resource string
Field string
Code string
}
func (err HTTPError) Error() string {
if msgs := strings.SplitN(err.Message, "\n", 2); len(msgs) > 1 {
return fmt.Sprintf("HTTP %d: %s (%s)\n%s", err.StatusCode, msgs[0], err.RequestURL, msgs[1])
} else if err.Message != "" {
return fmt.Sprintf("HTTP %d: %s (%s)", err.StatusCode, err.Message, err.RequestURL)
}
return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL)
}
func (err HTTPError) ScopesSuggestion() string {
return err.scopesSuggestion
}
// GraphQL performs a GraphQL request and parses the response. If there are errors in the response,
// GraphQLError will be returned, but the data will also be parsed into the receiver.
func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error {
opts := clientOptions(hostname, c.http.Transport)
opts.Headers[graphqlFeatures] = features
gqlClient, err := gh.GQLClient(&opts)
if err != nil {
return err
}
return handleResponse(gqlClient.Do(query, variables, data))
}
// GraphQL performs a GraphQL mutation and parses the response. If there are errors in the response,
// GraphQLError will be returned, but the data will also be parsed into the receiver.
func (c Client) Mutate(hostname, name string, mutation interface{}, variables map[string]interface{}) error {
opts := clientOptions(hostname, c.http.Transport)
opts.Headers[graphqlFeatures] = features
gqlClient, err := gh.GQLClient(&opts)
if err != nil {
return err
}
return handleResponse(gqlClient.Mutate(name, mutation, variables))
}
// GraphQL performs a GraphQL query and parses the response. If there are errors in the response,
// GraphQLError will be returned, but the data will also be parsed into the receiver.
func (c Client) Query(hostname, name string, query interface{}, variables map[string]interface{}) error {
opts := clientOptions(hostname, c.http.Transport)
opts.Headers[graphqlFeatures] = features
gqlClient, err := gh.GQLClient(&opts)
if err != nil {
return err
}
return handleResponse(gqlClient.Query(name, query, variables))
}
// REST performs a REST request and parses the response.
func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error {
opts := clientOptions(hostname, c.http.Transport)
restClient, err := gh.RESTClient(&opts)
if err != nil {
return err
}
return handleResponse(restClient.Do(method, p, body, data))
}
func (c Client) RESTWithNext(hostname string, method string, p string, body io.Reader, data interface{}) (string, error) {
opts := clientOptions(hostname, c.http.Transport)
restClient, err := gh.RESTClient(&opts)
if err != nil {
return "", err
}
resp, err := restClient.Request(method, p, body)
if err != nil {
return "", err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return "", HandleHTTPError(resp)
}
if resp.StatusCode == http.StatusNoContent {
return "", nil
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
err = json.Unmarshal(b, &data)
if err != nil {
return "", err
}
var next string
for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) {
if len(m) > 2 && m[2] == "next" {
next = m[1]
}
}
return next, nil
}
// HandleHTTPError parses a http.Response into a HTTPError.
func HandleHTTPError(resp *http.Response) error {
return handleResponse(ghAPI.HandleHTTPError(resp))
}
// handleResponse takes a ghAPI.HTTPError or ghAPI.GQLError and converts it into an
// HTTPError or GraphQLError respectively.
func handleResponse(err error) error {
if err == nil {
return nil
}
var restErr ghAPI.HTTPError
if errors.As(err, &restErr) {
return HTTPError{
HTTPError: restErr,
scopesSuggestion: generateScopesSuggestion(restErr.StatusCode,
restErr.Headers.Get("X-Accepted-Oauth-Scopes"),
restErr.Headers.Get("X-Oauth-Scopes"),
restErr.RequestURL.Hostname()),
}
}
var gqlErr ghAPI.GQLError
if errors.As(err, &gqlErr) {
return GraphQLError{
GQLError: gqlErr,
}
}
return err
}
// ScopesSuggestion is an error messaging utility that prints the suggestion to request additional OAuth
// scopes in case a server response indicates that there are missing scopes.
func ScopesSuggestion(resp *http.Response) string {
if resp.StatusCode < 400 || resp.StatusCode > 499 || resp.StatusCode == 422 {
return generateScopesSuggestion(resp.StatusCode,
resp.Header.Get("X-Accepted-Oauth-Scopes"),
resp.Header.Get("X-Oauth-Scopes"),
resp.Request.URL.Hostname())
}
// EndpointNeedsScopes adds additional OAuth scopes to an HTTP response as if they were returned from the
// server endpoint. This improves HTTP 4xx error messaging for endpoints that don't explicitly list the
// OAuth scopes they need.
func EndpointNeedsScopes(resp *http.Response, s string) *http.Response {
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
oldScopes := resp.Header.Get("X-Accepted-Oauth-Scopes")
resp.Header.Set("X-Accepted-Oauth-Scopes", fmt.Sprintf("%s, %s", oldScopes, s))
}
return resp
}
func generateScopesSuggestion(statusCode int, endpointNeedsScopes, tokenHasScopes, hostname string) string {
if statusCode < 400 || statusCode > 499 || statusCode == 422 {
return ""
}
endpointNeedsScopes := resp.Header.Get("X-Accepted-Oauth-Scopes")
tokenHasScopes := resp.Header.Get("X-Oauth-Scopes")
if tokenHasScopes == "" {
return ""
}
@ -220,7 +205,23 @@ func ScopesSuggestion(resp *http.Response) string {
for _, s := range strings.Split(tokenHasScopes, ",") {
s = strings.TrimSpace(s)
gotScopes[s] = struct{}{}
if strings.HasPrefix(s, "admin:") {
// Certain scopes may be grouped under a single "top-level" scope. The following branch
// statements include these grouped/implied scopes when the top-level scope is encountered.
// See https://docs.github.com/en/developers/apps/building-oauth-apps/scopes-for-oauth-apps.
if s == "repo" {
gotScopes["repo:status"] = struct{}{}
gotScopes["repo_deployment"] = struct{}{}
gotScopes["public_repo"] = struct{}{}
gotScopes["repo:invite"] = struct{}{}
gotScopes["security_events"] = struct{}{}
} else if s == "user" {
gotScopes["read:user"] = struct{}{}
gotScopes["user:email"] = struct{}{}
gotScopes["user:follow"] = struct{}{}
} else if s == "codespace" {
gotScopes["codespace:secrets"] = struct{}{}
} else if strings.HasPrefix(s, "admin:") {
gotScopes["read:"+strings.TrimPrefix(s, "admin:")] = struct{}{}
gotScopes["write:"+strings.TrimPrefix(s, "admin:")] = struct{}{}
} else if strings.HasPrefix(s, "write:") {
@ -236,190 +237,25 @@ func ScopesSuggestion(resp *http.Response) string {
return fmt.Sprintf(
"This API operation needs the %[1]q scope. To request it, run: gh auth refresh -h %[2]s -s %[1]s",
s,
ghinstance.NormalizeHostname(resp.Request.URL.Hostname()),
ghinstance.NormalizeHostname(hostname),
)
}
return ""
}
// EndpointNeedsScopes adds additional OAuth scopes to an HTTP response as if they were returned from the
// server endpoint. This improves HTTP 4xx error messaging for endpoints that don't explicitly list the
// OAuth scopes they need.
func EndpointNeedsScopes(resp *http.Response, s string) *http.Response {
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
oldScopes := resp.Header.Get("X-Accepted-Oauth-Scopes")
resp.Header.Set("X-Accepted-Oauth-Scopes", fmt.Sprintf("%s, %s", oldScopes, s))
func clientOptions(hostname string, transport http.RoundTripper) ghAPI.ClientOptions {
// AuthToken, and Headers are being handled by transport,
// so let go-gh know that it does not need to resolve them.
opts := ghAPI.ClientOptions{
AuthToken: "none",
Headers: map[string]string{
authorization: "",
},
Host: hostname,
SkipDefaultHeaders: true,
Transport: transport,
LogIgnoreEnv: true,
}
return resp
}
// GraphQL performs a GraphQL request and parses the response. If there are errors in the response,
// *GraphQLErrorResponse will be returned, but the data will also be parsed into the receiver.
func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error {
reqBody, err := json.Marshal(map[string]interface{}{"query": query, "variables": variables})
if err != nil {
return err
}
req, err := http.NewRequest("POST", ghinstance.GraphQLEndpoint(hostname), bytes.NewBuffer(reqBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return handleResponse(resp, data)
}
func graphQLClient(h *http.Client, hostname string) *graphql.Client {
return graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), h)
}
// REST performs a REST request and parses the response.
func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error {
req, err := http.NewRequest(method, restURL(hostname, p), body)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return HandleHTTPError(resp)
}
if resp.StatusCode == http.StatusNoContent {
return nil
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
err = json.Unmarshal(b, &data)
if err != nil {
return err
}
return nil
}
func restURL(hostname string, pathOrURL string) string {
if strings.HasPrefix(pathOrURL, "https://") || strings.HasPrefix(pathOrURL, "http://") {
return pathOrURL
}
return ghinstance.RESTPrefix(hostname) + pathOrURL
}
func handleResponse(resp *http.Response, data interface{}) error {
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return HandleHTTPError(resp)
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
gr := &graphQLResponse{Data: data}
err = json.Unmarshal(body, &gr)
if err != nil {
return err
}
if len(gr.Errors) > 0 {
return &GraphQLErrorResponse{Errors: gr.Errors}
}
return nil
}
func HandleHTTPError(resp *http.Response) error {
httpError := HTTPError{
StatusCode: resp.StatusCode,
RequestURL: resp.Request.URL,
scopesSuggestion: ScopesSuggestion(resp),
}
if !jsonTypeRE.MatchString(resp.Header.Get("Content-Type")) {
httpError.Message = resp.Status
return httpError
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
httpError.Message = err.Error()
return httpError
}
var parsedBody struct {
Message string `json:"message"`
Errors []json.RawMessage
}
if err := json.Unmarshal(body, &parsedBody); err != nil {
return httpError
}
var messages []string
if parsedBody.Message != "" {
messages = append(messages, parsedBody.Message)
}
for _, raw := range parsedBody.Errors {
switch raw[0] {
case '"':
var errString string
_ = json.Unmarshal(raw, &errString)
messages = append(messages, errString)
httpError.Errors = append(httpError.Errors, HTTPErrorItem{Message: errString})
case '{':
var errInfo HTTPErrorItem
_ = json.Unmarshal(raw, &errInfo)
msg := errInfo.Message
if errInfo.Code != "" && errInfo.Code != "custom" {
msg = fmt.Sprintf("%s.%s %s", errInfo.Resource, errInfo.Field, errorCodeToMessage(errInfo.Code))
}
if msg != "" {
messages = append(messages, msg)
}
httpError.Errors = append(httpError.Errors, errInfo)
}
}
httpError.Message = strings.Join(messages, "\n")
return httpError
}
func errorCodeToMessage(code string) string {
// https://docs.github.com/en/rest/overview/resources-in-the-rest-api#client-errors
switch code {
case "missing", "missing_field":
return "is missing"
case "invalid", "unprocessable":
return "is invalid"
case "already_exists":
return "already exists"
default:
return code
}
}
var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
func inspectableMIMEType(t string) bool {
return strings.HasPrefix(t, "text/") || jsonTypeRE.MatchString(t)
return opts
}

View file

@ -3,20 +3,25 @@ package api
import (
"bytes"
"errors"
"io/ioutil"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
)
func newTestClient(reg *httpmock.Registry) *Client {
client := &http.Client{}
httpmock.ReplaceTripper(client, reg)
return NewClientFromHTTP(client)
}
func TestGraphQL(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(
ReplaceTripper(http),
AddHeader("Authorization", "token OTOKEN"),
)
client := newTestClient(http)
vars := map[string]interface{}{"name": "Mona"}
response := struct {
@ -35,18 +40,17 @@ func TestGraphQL(t *testing.T) {
assert.Equal(t, "hubot", response.Viewer.Login)
req := http.Requests[0]
reqBody, _ := ioutil.ReadAll(req.Body)
reqBody, _ := io.ReadAll(req.Body)
assert.Equal(t, `{"query":"QUERY","variables":{"name":"Mona"}}`, string(reqBody))
assert.Equal(t, "token OTOKEN", req.Header.Get("Authorization"))
}
func TestGraphQLError(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
reg := &httpmock.Registry{}
client := newTestClient(reg)
response := struct{}{}
http.Register(
reg.Register(
httpmock.GraphQL(""),
httpmock.StringResponse(`
{ "errors": [
@ -73,10 +77,7 @@ func TestGraphQLError(t *testing.T) {
func TestRESTGetDelete(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(
ReplaceTripper(http),
)
client := newTestClient(http)
http.Register(
httpmock.REST("DELETE", "applications/CLIENTID/grant"),
@ -90,7 +91,7 @@ func TestRESTGetDelete(t *testing.T) {
func TestRESTWithFullURL(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
client := newTestClient(http)
http.Register(
httpmock.REST("GET", "api/v3/user/repos"),
@ -110,13 +111,13 @@ func TestRESTWithFullURL(t *testing.T) {
func TestRESTError(t *testing.T) {
fakehttp := &httpmock.Registry{}
client := NewClient(ReplaceTripper(fakehttp))
client := newTestClient(fakehttp)
fakehttp.Register(httpmock.MatchAny, func(req *http.Request) (*http.Response, error) {
return &http.Response{
Request: req,
StatusCode: 422,
Body: ioutil.NopCloser(bytes.NewBufferString(`{"message": "OH NO"}`)),
Body: io.NopCloser(bytes.NewBufferString(`{"message": "OH NO"}`)),
Header: map[string][]string{
"Content-Type": {"application/json; charset=utf-8"},
},
@ -134,7 +135,6 @@ func TestRESTError(t *testing.T) {
}
if httpErr.Error() != "HTTP 422: OH NO (https://api.github.com/repos/branch)" {
t.Errorf("got %q", httpErr.Error())
}
}
@ -146,7 +146,7 @@ func TestHandleHTTPError_GraphQL502(t *testing.T) {
resp := &http.Response{
Request: req,
StatusCode: 502,
Body: ioutil.NopCloser(bytes.NewBufferString(`{ "data": null, "errors": [{ "message": "Something went wrong" }] }`)),
Body: io.NopCloser(bytes.NewBufferString(`{ "data": null, "errors": [{ "message": "Something went wrong" }] }`)),
Header: map[string][]string{"Content-Type": {"application/json"}},
}
err = HandleHTTPError(resp)
@ -164,7 +164,7 @@ func TestHTTPError_ScopesSuggestion(t *testing.T) {
return &http.Response{
Request: req,
StatusCode: s,
Body: ioutil.NopCloser(bytes.NewBufferString(`{}`)),
Body: io.NopCloser(bytes.NewBufferString(`{}`)),
Header: map[string][]string{
"Content-Type": {"application/json"},
"X-Oauth-Scopes": {haveScopes},
@ -223,3 +223,36 @@ func TestHTTPError_ScopesSuggestion(t *testing.T) {
})
}
}
func TestHTTPHeaders(t *testing.T) {
var gotReq *http.Request
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotReq = r
w.WriteHeader(http.StatusNoContent)
}))
defer ts.Close()
ios, _, _, stderr := iostreams.Test()
httpClient, err := NewHTTPClient(HTTPClientOptions{
AppVersion: "v1.2.3",
Config: tinyConfig{ts.URL[7:] + ":oauth_token": "MYTOKEN"},
Log: ios.ErrOut,
SkipAcceptHeaders: false,
})
assert.NoError(t, err)
client := NewClientFromHTTP(httpClient)
err = client.REST(ts.URL, "GET", ts.URL+"/user/repos", nil, nil)
assert.NoError(t, err)
wantHeader := map[string]string{
"Accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview",
"Authorization": "token MYTOKEN",
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "GitHub CLI v1.2.3",
}
for name, value := range wantHeader {
assert.Equal(t, value, gotReq.Header.Get(name), name)
}
assert.Equal(t, "", stderr.String())
}

View file

@ -50,7 +50,30 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} {
data[f] = pr.HeadRepository
case "statusCheckRollup":
if n := pr.StatusCheckRollup.Nodes; len(n) > 0 {
data[f] = n[0].Commit.StatusCheckRollup.Contexts.Nodes
checks := make([]interface{}, 0, len(n[0].Commit.StatusCheckRollup.Contexts.Nodes))
for _, c := range n[0].Commit.StatusCheckRollup.Contexts.Nodes {
if c.TypeName == "CheckRun" {
checks = append(checks, map[string]interface{}{
"__typename": c.TypeName,
"name": c.Name,
"workflowName": c.CheckSuite.WorkflowRun.Workflow.Name,
"status": c.Status,
"conclusion": c.Conclusion,
"startedAt": c.StartedAt,
"completedAt": c.CompletedAt,
"detailsUrl": c.DetailsURL,
})
} else {
checks = append(checks, map[string]interface{}{
"__typename": c.TypeName,
"context": c.Context,
"state": c.State,
"targetUrl": c.TargetURL,
"startedAt": c.CreatedAt,
})
}
}
data[f] = checks
} else {
data[f] = nil
}
@ -87,6 +110,8 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} {
data[f] = pr.ProjectCards.Nodes
case "reviews":
data[f] = pr.Reviews.Nodes
case "latestReviews":
data[f] = pr.LatestReviews.Nodes
case "files":
data[f] = pr.Files.Nodes
case "reviewRequests":

View file

@ -140,11 +140,19 @@ func TestPullRequest_ExportData(t *testing.T) {
{
"__typename": "CheckRun",
"name": "mycheck",
"checkSuite": {"workflowRun": {"workflow": {"name": "myworkflow"}}},
"status": "COMPLETED",
"conclusion": "SUCCESS",
"startedAt": "2020-08-31T15:44:24+02:00",
"completedAt": "2020-08-31T15:45:24+02:00",
"detailsUrl": "http://example.com/details"
},
{
"__typename": "StatusContext",
"context": "mycontext",
"state": "SUCCESS",
"createdAt": "2020-08-31T15:44:24+02:00",
"targetUrl": "http://example.com/details"
}
] } } } }
] } }
@ -155,11 +163,19 @@ func TestPullRequest_ExportData(t *testing.T) {
{
"__typename": "CheckRun",
"name": "mycheck",
"workflowName": "myworkflow",
"status": "COMPLETED",
"conclusion": "SUCCESS",
"startedAt": "2020-08-31T15:44:24+02:00",
"completedAt": "2020-08-31T15:45:24+02:00",
"detailsUrl": "http://example.com/details"
},
{
"__typename": "StatusContext",
"context": "mycontext",
"state": "SUCCESS",
"startedAt": "2020-08-31T15:44:24+02:00",
"targetUrl": "http://example.com/details"
}
]
}
@ -178,7 +194,14 @@ func TestPullRequest_ExportData(t *testing.T) {
enc := json.NewEncoder(&buf)
enc.SetIndent("", "\t")
require.NoError(t, enc.Encode(exported))
assert.Equal(t, tt.outputJSON, buf.String())
var gotData interface{}
dec = json.NewDecoder(&buf)
require.NoError(t, dec.Decode(&gotData))
var expectData interface{}
require.NoError(t, json.Unmarshal([]byte(tt.outputJSON), &expectData))
assert.Equal(t, expectData, gotData)
})
}
}

131
api/http_client.go Normal file
View file

@ -0,0 +1,131 @@
package api
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/utils"
"github.com/cli/go-gh"
ghAPI "github.com/cli/go-gh/pkg/api"
)
type tokenGetter interface {
AuthToken(string) (string, string)
}
type HTTPClientOptions struct {
AppVersion string
CacheTTL time.Duration
Config tokenGetter
EnableCache bool
Log io.Writer
LogColorize bool
SkipAcceptHeaders bool
}
func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
// Provide invalid host, and token values so gh.HTTPClient will not automatically resolve them.
// The real host and token are inserted at request time.
clientOpts := ghAPI.ClientOptions{
Host: "none",
AuthToken: "none",
LogIgnoreEnv: true,
}
if debugEnabled, debugValue := utils.IsDebugEnabled(); debugEnabled {
clientOpts.Log = opts.Log
clientOpts.LogColorize = opts.LogColorize
clientOpts.LogVerboseHTTP = strings.Contains(debugValue, "api")
}
headers := map[string]string{
userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion),
}
if opts.SkipAcceptHeaders {
headers[accept] = ""
}
clientOpts.Headers = headers
if opts.EnableCache {
clientOpts.EnableCache = opts.EnableCache
clientOpts.CacheTTL = opts.CacheTTL
}
client, err := gh.HTTPClient(&clientOpts)
if err != nil {
return nil, err
}
if opts.Config != nil {
client.Transport = AddAuthTokenHeader(client.Transport, opts.Config)
}
return client, nil
}
func NewCachedHTTPClient(httpClient *http.Client, ttl time.Duration) *http.Client {
newClient := *httpClient
newClient.Transport = AddCacheTTLHeader(httpClient.Transport, ttl)
return &newClient
}
// AddCacheTTLHeader adds an header to the request telling the cache that the request
// should be cached for a specified amount of time.
func AddCacheTTLHeader(rt http.RoundTripper, ttl time.Duration) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
// If the header is already set in the request, don't overwrite it.
if req.Header.Get(cacheTTL) == "" {
req.Header.Set(cacheTTL, ttl.String())
}
return rt.RoundTrip(req)
}}
}
// AddAuthToken adds an authentication token header for the host specified by the request.
func AddAuthTokenHeader(rt http.RoundTripper, cfg tokenGetter) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
// If the header is already set in the request, don't overwrite it.
if req.Header.Get(authorization) == "" {
hostname := ghinstance.NormalizeHostname(getHost(req))
if token, _ := cfg.AuthToken(hostname); token != "" {
req.Header.Set(authorization, fmt.Sprintf("token %s", token))
}
}
return rt.RoundTrip(req)
}}
}
// ExtractHeader extracts a named header from any response received by this client and,
// if non-blank, saves it to dest.
func ExtractHeader(name string, dest *string) func(http.RoundTripper) http.RoundTripper {
return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
res, err := tr.RoundTrip(req)
if err == nil {
if value := res.Header.Get(name); value != "" {
*dest = value
}
}
return res, err
}}
}
}
type funcTripper struct {
roundTrip func(*http.Request) (*http.Response, error)
}
func (tr funcTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return tr.roundTrip(req)
}
func getHost(r *http.Request) string {
if r.Host != "" {
return r.Host
}
return r.URL.Hostname()
}

View file

@ -1,4 +1,4 @@
package factory
package api
import (
"fmt"
@ -16,7 +16,7 @@ import (
func TestNewHTTPClient(t *testing.T) {
type args struct {
config configGetter
config tokenGetter
appVersion string
setAccept bool
}
@ -24,6 +24,8 @@ func TestNewHTTPClient(t *testing.T) {
name string
args args
envDebug string
setGhDebug bool
envGhDebug string
host string
wantHeader map[string]string
wantStderr string
@ -80,8 +82,9 @@ func TestNewHTTPClient(t *testing.T) {
appVersion: "v1.2.3",
setAccept: true,
},
host: "github.com",
envDebug: "api",
host: "github.com",
envDebug: "api",
setGhDebug: false,
wantHeader: map[string]string{
"authorization": "token MYTOKEN",
"user-agent": "GitHub CLI v1.2.3",
@ -94,11 +97,45 @@ func TestNewHTTPClient(t *testing.T) {
> Host: github.com
> Accept: application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview
> Authorization: token
> Content-Type: application/json; charset=utf-8
> Time-Zone: <timezone>
> User-Agent: GitHub CLI v1.2.3
< HTTP/1.1 204 No Content
< Date: <time>
* Request took <duration>
`),
},
{
name: "github.com in verbose mode",
args: args{
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: true,
},
host: "github.com",
envGhDebug: "api",
setGhDebug: true,
wantHeader: map[string]string{
"authorization": "token MYTOKEN",
"user-agent": "GitHub CLI v1.2.3",
"accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview",
},
wantStderr: heredoc.Doc(`
* Request at <time>
* Request to http://<host>:<port>
> GET / HTTP/1.1
> Host: github.com
> Accept: application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview
> Authorization: token
> Content-Type: application/json; charset=utf-8
> Time-Zone: <timezone>
> User-Agent: GitHub CLI v1.2.3
< HTTP/1.1 204 No Content
< Date: <time>
* Request took <duration>
`),
},
@ -113,7 +150,7 @@ func TestNewHTTPClient(t *testing.T) {
wantHeader: map[string]string{
"authorization": "token GHETOKEN",
"user-agent": "GitHub CLI v1.2.3",
"accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview, application/vnd.github.antiope-preview, application/vnd.github.shadow-cat-preview",
"accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview",
},
wantStderr: "",
},
@ -128,21 +165,29 @@ func TestNewHTTPClient(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oldDebug := os.Getenv("DEBUG")
os.Setenv("DEBUG", tt.envDebug)
t.Cleanup(func() {
os.Setenv("DEBUG", oldDebug)
})
t.Setenv("DEBUG", tt.envDebug)
if tt.setGhDebug {
t.Setenv("GH_DEBUG", tt.envGhDebug)
} else {
os.Unsetenv("GH_DEBUG")
}
io, _, _, stderr := iostreams.Test()
client, err := NewHTTPClient(io, tt.args.config, tt.args.appVersion, tt.args.setAccept)
ios, _, _, stderr := iostreams.Test()
client, err := NewHTTPClient(HTTPClientOptions{
AppVersion: tt.args.appVersion,
Config: tt.args.config,
Log: ios.ErrOut,
SkipAcceptHeaders: !tt.args.setAccept,
})
require.NoError(t, err)
req, err := http.NewRequest("GET", ts.URL, nil)
req.Header.Set("time-zone", "Europe/Amsterdam")
req.Host = tt.host
require.NoError(t, err)
res, err := client.Do(req)
require.NoError(t, err)
for name, value := range tt.wantHeader {
@ -157,19 +202,21 @@ func TestNewHTTPClient(t *testing.T) {
type tinyConfig map[string]string
func (c tinyConfig) Get(host, key string) (string, error) {
return c[fmt.Sprintf("%s:%s", host, key)], nil
func (c tinyConfig) AuthToken(host string) (string, string) {
return c[fmt.Sprintf("%s:%s", host, "oauth_token")], "oauth_token"
}
var requestAtRE = regexp.MustCompile(`(?m)^\* Request at .+`)
var dateRE = regexp.MustCompile(`(?m)^< Date: .+`)
var hostWithPortRE = regexp.MustCompile(`127\.0\.0\.1:\d+`)
var durationRE = regexp.MustCompile(`(?m)^\* Request took .+`)
var timezoneRE = regexp.MustCompile(`(?m)^> Time-Zone: .+`)
func normalizeVerboseLog(t string) string {
t = requestAtRE.ReplaceAllString(t, "* Request at <time>")
t = hostWithPortRE.ReplaceAllString(t, "<host>:<port>")
t = dateRE.ReplaceAllString(t, "< Date: <time>")
t = durationRE.ReplaceAllString(t, "* Request took <duration>")
t = timezoneRE.ReplaceAllString(t, "> Time-Zone: <timezone>")
return t
}

View file

@ -0,0 +1,219 @@
package api
import (
"fmt"
"github.com/cli/cli/v2/internal/ghrepo"
)
type LinkedBranch struct {
ID string
BranchName string
RepoUrl string
}
// method to return url of linked branch, adds the branch name to the end of the repo url
func (b *LinkedBranch) Url() string {
return fmt.Sprintf("%s/tree/%s", b.RepoUrl, b.BranchName)
}
func nameParam(params map[string]interface{}) string {
if params["name"] != "" {
return "name: $name,"
}
return ""
}
func nameArg(params map[string]interface{}) string {
if params["name"] != "" {
return "$name: String, "
}
return ""
}
func CreateBranchIssueReference(client *Client, repo *Repository, params map[string]interface{}) (*LinkedBranch, error) {
query := fmt.Sprintf(`
mutation CreateLinkedBranch($issueId: ID!, $oid: GitObjectID!, %[1]s$repositoryId: ID) {
createLinkedBranch(input: {
issueId: $issueId,
%[2]s
oid: $oid,
repositoryId: $repositoryId
}) {
linkedBranch {
id
ref {
name
}
}
}
}`, nameArg(params), nameParam(params))
inputParams := map[string]interface{}{
"repositoryId": repo.ID,
}
for key, val := range params {
switch key {
case "issueId", "name", "oid":
inputParams[key] = val
}
}
result := struct {
CreateLinkedBranch struct {
LinkedBranch struct {
ID string
Ref struct {
Name string
}
}
}
}{}
err := client.GraphQL(repo.RepoHost(), query, inputParams, &result)
if err != nil {
return nil, err
}
ref := LinkedBranch{
ID: result.CreateLinkedBranch.LinkedBranch.ID,
BranchName: result.CreateLinkedBranch.LinkedBranch.Ref.Name,
}
return &ref, nil
}
func ListLinkedBranches(client *Client, repo ghrepo.Interface, issueNumber int) ([]LinkedBranch, error) {
query := `
query BranchIssueReferenceListLinkedBranches($repositoryName: String!, $repositoryOwner: String!, $issueNumber: Int!) {
repository(name: $repositoryName, owner: $repositoryOwner) {
issue(number: $issueNumber) {
linkedBranches(first: 30) {
edges {
node {
ref {
name
repository {
url
}
}
}
}
}
}
}
}
`
variables := map[string]interface{}{
"repositoryName": repo.RepoName(),
"repositoryOwner": repo.RepoOwner(),
"issueNumber": issueNumber,
}
result := struct {
Repository struct {
Issue struct {
LinkedBranches struct {
Edges []struct {
Node struct {
Ref struct {
Name string
Repository struct {
NameWithOwner string
Url string
}
}
}
}
}
}
}
}{}
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
var branchNames []LinkedBranch
if err != nil {
return branchNames, err
}
for _, edge := range result.Repository.Issue.LinkedBranches.Edges {
branch := LinkedBranch{
BranchName: edge.Node.Ref.Name,
RepoUrl: edge.Node.Ref.Repository.Url,
}
branchNames = append(branchNames, branch)
}
return branchNames, nil
}
// introspects the schema to see if we expose the LinkedBranch type
func CheckLinkedBranchFeature(client *Client, host string) (err error) {
var featureDetection struct {
Name struct {
Fields []struct {
Name string
}
} `graphql:"LinkedBranch: __type(name: \"LinkedBranch\")"`
}
err = client.Query(host, "LinkedBranch_fields", &featureDetection, nil)
if err != nil {
return err
}
if len(featureDetection.Name.Fields) == 0 {
return fmt.Errorf("the `gh issue develop` command is not currently available")
}
return nil
}
// This fetches the oids for the repo's default branch (`main`, etc) and the name the user might have provided in one shot.
func FindBaseOid(client *Client, repo *Repository, ref string) (string, string, error) {
query := `
query BranchIssueReferenceFindBaseOid($repositoryName: String!, $repositoryOwner: String!, $ref: String!) {
repository(name: $repositoryName, owner: $repositoryOwner) {
defaultBranchRef {
target {
oid
}
}
ref(qualifiedName: $ref) {
target {
oid
}
}
}
}`
variables := map[string]interface{}{
"repositoryName": repo.Name,
"repositoryOwner": repo.RepoOwner(),
"ref": ref,
}
result := struct {
Repository struct {
DefaultBranchRef struct {
Target struct {
Oid string
}
}
Ref struct {
Target struct {
Oid string
}
}
}
}{}
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
if err != nil {
return "", "", err
}
return result.Repository.Ref.Target.Oid, result.Repository.DefaultBranchRef.Target.Oid, nil
}

View file

@ -1,10 +1,8 @@
package api
import (
"context"
"time"
graphql "github.com/cli/shurcooL-graphql"
"github.com/shurcooL/githubv4"
)
@ -17,7 +15,18 @@ type Comments struct {
}
}
func (cs Comments) CurrentUserComments() []Comment {
var comments []Comment
for _, c := range cs.Nodes {
if c.ViewerDidAuthor {
comments = append(comments, c)
}
}
return comments
}
type Comment struct {
ID string `json:"id"`
Author Author `json:"author"`
AuthorAssociation string `json:"authorAssociation"`
Body string `json:"body"`
@ -26,6 +35,8 @@ type Comment struct {
IsMinimized bool `json:"isMinimized"`
MinimizedReason string `json:"minimizedReason"`
ReactionGroups ReactionGroups `json:"reactionGroups"`
URL string `json:"url,omitempty"`
ViewerDidAuthor bool `json:"viewerDidAuthor"`
}
type CommentCreateInput struct {
@ -33,6 +44,11 @@ type CommentCreateInput struct {
SubjectId string
}
type CommentUpdateInput struct {
Body string
CommentId string
}
func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (string, error) {
var mutation struct {
AddComment struct {
@ -47,12 +63,11 @@ func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (
variables := map[string]interface{}{
"input": githubv4.AddCommentInput{
Body: githubv4.String(params.Body),
SubjectID: graphql.ID(params.SubjectId),
SubjectID: githubv4.ID(params.SubjectId),
},
}
gql := graphQLClient(client.http, repoHost)
err := gql.MutateNamed(context.Background(), "CommentCreate", &mutation, variables)
err := client.Mutate(repoHost, "CommentCreate", &mutation, variables)
if err != nil {
return "", err
}
@ -60,6 +75,34 @@ func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (
return mutation.AddComment.CommentEdge.Node.URL, nil
}
func CommentUpdate(client *Client, repoHost string, params CommentUpdateInput) (string, error) {
var mutation struct {
UpdateIssueComment struct {
IssueComment struct {
URL string
}
} `graphql:"updateIssueComment(input: $input)"`
}
variables := map[string]interface{}{
"input": githubv4.UpdateIssueCommentInput{
Body: githubv4.String(params.Body),
ID: githubv4.ID(params.CommentId),
},
}
err := client.Mutate(repoHost, "CommentUpdate", &mutation, variables)
if err != nil {
return "", err
}
return mutation.UpdateIssueComment.IssueComment.URL, nil
}
func (c Comment) Identifier() string {
return c.ID
}
func (c Comment) AuthorLogin() string {
return c.Author.Login
}
@ -89,7 +132,7 @@ func (c Comment) IsHidden() bool {
}
func (c Comment) Link() string {
return ""
return c.URL
}
func (c Comment) Reactions() ReactionGroups {

View file

@ -26,6 +26,7 @@ type Issue struct {
Title string
URL string
State string
StateReason string
Closed bool
Body string
CreatedAt time.Time
@ -38,6 +39,7 @@ type Issue struct {
ProjectCards ProjectCards
Milestone *Milestone
ReactionGroups ReactionGroups
IsPinned bool
}
func (i Issue) IsPullRequest() bool {
@ -178,7 +180,7 @@ func IssueStatus(client *Client, repo ghrepo.Interface, options IssueStatusOptio
}
}
fragments := fmt.Sprintf("fragment issue on Issue{%s}", PullRequestGraphQL(options.Fields))
fragments := fmt.Sprintf("fragment issue on Issue{%s}", IssueGraphQL(options.Fields))
query := fragments + `
query IssueStatus($owner: String!, $repo: String!, $viewer: String!, $per_page: Int = 10) {
repository(owner: $owner, name: $repo) {
@ -245,3 +247,7 @@ func (i Issue) Link() string {
func (i Issue) Identifier() string {
return i.ID
}
func (i Issue) CurrentUserComments() []Comment {
return i.Comments.CurrentUserComments()
}

View file

@ -1,8 +1,6 @@
package api
import (
"context"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/shurcooL/githubv4"
)
@ -26,12 +24,10 @@ func OrganizationProjects(client *Client, repo ghrepo.Interface) ([]RepoProject,
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var projects []RepoProject
for {
var query responseData
err := gql.QueryNamed(context.Background(), "OrganizationProjectList", &query, variables)
err := client.Query(repo.RepoHost(), "OrganizationProjectList", &query, variables)
if err != nil {
return nil, err
}
@ -70,12 +66,10 @@ func OrganizationTeams(client *Client, repo ghrepo.Interface) ([]OrgTeam, error)
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var teams []OrgTeam
for {
var query responseData
err := gql.QueryNamed(context.Background(), "OrganizationTeamList", &query, variables)
err := client.Query(repo.RepoHost(), "OrganizationTeamList", &query, variables)
if err != nil {
return nil, err
}

View file

@ -1,26 +1,15 @@
package api
import (
"context"
"fmt"
"net/http"
"strings"
"net/url"
"time"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/set"
"github.com/shurcooL/githubv4"
"golang.org/x/sync/errgroup"
)
type PullRequestsPayload struct {
ViewerCreated PullRequestAndTotalCount
ReviewRequested PullRequestAndTotalCount
CurrentPR *PullRequest
DefaultBranch string
}
type PullRequestAndTotalCount struct {
TotalCount int
PullRequests []PullRequest
@ -28,24 +17,27 @@ type PullRequestAndTotalCount struct {
}
type PullRequest struct {
ID string
Number int
Title string
State string
Closed bool
URL string
BaseRefName string
HeadRefName string
Body string
Mergeable string
Additions int
Deletions int
ChangedFiles int
MergeStateStatus string
CreatedAt time.Time
UpdatedAt time.Time
ClosedAt *time.Time
MergedAt *time.Time
ID string
Number int
Title string
State string
Closed bool
URL string
BaseRefName string
HeadRefName string
HeadRefOid string
Body string
Mergeable string
Additions int
Deletions int
ChangedFiles int
MergeStateStatus string
IsInMergeQueue bool
IsMergeQueueEnabled bool // Indicates whether the pull request's base ref has a merge queue enabled.
CreatedAt time.Time
UpdatedAt time.Time
ClosedAt *time.Time
MergedAt *time.Time
MergeCommit *Commit
PotentialMergeCommit *Commit
@ -64,7 +56,8 @@ type PullRequest struct {
BaseRef struct {
BranchProtectionRule struct {
RequiresStrictStatusChecks bool
RequiresStrictStatusChecks bool
RequiredApprovingReviewCount int
}
}
@ -75,30 +68,7 @@ type PullRequest struct {
Nodes []PullRequestCommit
}
StatusCheckRollup struct {
Nodes []struct {
Commit struct {
StatusCheckRollup struct {
Contexts struct {
Nodes []struct {
TypeName string `json:"__typename"`
Name string `json:"name"`
Context string `json:"context,omitempty"`
State string `json:"state,omitempty"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
StartedAt time.Time `json:"startedAt"`
CompletedAt time.Time `json:"completedAt"`
DetailsURL string `json:"detailsUrl"`
TargetURL string `json:"targetUrl,omitempty"`
}
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
}
}
}
Nodes []StatusCheckRollupNode
}
Assignees Assignees
@ -108,9 +78,58 @@ type PullRequest struct {
Comments Comments
ReactionGroups ReactionGroups
Reviews PullRequestReviews
LatestReviews PullRequestReviews
ReviewRequests ReviewRequests
}
type StatusCheckRollupNode struct {
Commit StatusCheckRollupCommit
}
type StatusCheckRollupCommit struct {
StatusCheckRollup CommitStatusCheckRollup
}
type CommitStatusCheckRollup struct {
Contexts CheckContexts
}
type CheckContexts struct {
Nodes []CheckContext
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
type CheckContext struct {
TypeName string `json:"__typename"`
Name string `json:"name"`
IsRequired bool `json:"isRequired"`
CheckSuite struct {
WorkflowRun struct {
Workflow struct {
Name string `json:"name"`
} `json:"workflow"`
} `json:"workflowRun"`
} `json:"checkSuite"`
// QUEUED IN_PROGRESS COMPLETED WAITING PENDING REQUESTED
Status string `json:"status"`
// ACTION_REQUIRED TIMED_OUT CANCELLED FAILURE SUCCESS NEUTRAL SKIPPED STARTUP_FAILURE STALE
Conclusion string `json:"conclusion"`
StartedAt time.Time `json:"startedAt"`
CompletedAt time.Time `json:"completedAt"`
DetailsURL string `json:"detailsUrl"`
/* StatusContext fields */
Context string `json:"context"`
// EXPECTED ERROR FAILURE PENDING SUCCESS
State string `json:"state"`
TargetURL string `json:"targetUrl"`
CreatedAt time.Time `json:"createdAt"`
}
type PRRepository struct {
ID string `json:"id"`
Name string `json:"name"`
@ -195,6 +214,10 @@ func (pr PullRequest) Identifier() string {
return pr.ID
}
func (pr PullRequest) CurrentUserComments() []Comment {
return pr.Comments.CurrentUserComments()
}
func (pr PullRequest) IsOpen() bool {
return pr.State == "OPEN"
}
@ -250,6 +273,7 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) {
}
summary.Total++
}
return
}
@ -265,270 +289,6 @@ func (pr *PullRequest) DisplayableReviews() PullRequestReviews {
return PullRequestReviews{Nodes: published, TotalCount: len(published)}
}
type pullRequestFeature struct {
HasReviewDecision bool
HasStatusCheckRollup bool
HasBranchProtectionRule bool
}
func determinePullRequestFeatures(httpClient *http.Client, hostname string) (prFeatures pullRequestFeature, err error) {
if !ghinstance.IsEnterprise(hostname) {
prFeatures.HasReviewDecision = true
prFeatures.HasStatusCheckRollup = true
prFeatures.HasBranchProtectionRule = true
return
}
var featureDetection struct {
PullRequest struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"PullRequest: __type(name: \"PullRequest\")"`
Commit struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"Commit: __type(name: \"Commit\")"`
}
// needs to be a separate query because the backend only supports 2 `__type` expressions in one query
var featureDetection2 struct {
Ref struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"Ref: __type(name: \"Ref\")"`
}
v4 := graphQLClient(httpClient, hostname)
g := new(errgroup.Group)
g.Go(func() error {
return v4.QueryNamed(context.Background(), "PullRequest_fields", &featureDetection, nil)
})
g.Go(func() error {
return v4.QueryNamed(context.Background(), "PullRequest_fields2", &featureDetection2, nil)
})
err = g.Wait()
if err != nil {
return
}
for _, field := range featureDetection.PullRequest.Fields {
switch field.Name {
case "reviewDecision":
prFeatures.HasReviewDecision = true
}
}
for _, field := range featureDetection.Commit.Fields {
switch field.Name {
case "statusCheckRollup":
prFeatures.HasStatusCheckRollup = true
}
}
for _, field := range featureDetection2.Ref.Fields {
switch field.Name {
case "branchProtectionRule":
prFeatures.HasBranchProtectionRule = true
}
}
return
}
type StatusOptions struct {
CurrentPR int
HeadRef string
Username string
Fields []string
}
func PullRequestStatus(client *Client, repo ghrepo.Interface, options StatusOptions) (*PullRequestsPayload, error) {
type edges struct {
TotalCount int
Edges []struct {
Node PullRequest
}
}
type response struct {
Repository struct {
DefaultBranchRef struct {
Name string
}
PullRequests edges
PullRequest *PullRequest
}
ViewerCreated edges
ReviewRequested edges
}
var fragments string
if len(options.Fields) > 0 {
fields := set.NewStringSet()
fields.AddValues(options.Fields)
// these are always necessary to find the PR for the current branch
fields.AddValues([]string{"isCrossRepository", "headRepositoryOwner", "headRefName"})
gr := PullRequestGraphQL(fields.ToSlice())
fragments = fmt.Sprintf("fragment pr on PullRequest{%s}fragment prWithReviews on PullRequest{...pr}", gr)
} else {
var err error
fragments, err = pullRequestFragment(client.http, repo.RepoHost())
if err != nil {
return nil, err
}
}
queryPrefix := `
query PullRequestStatus($owner: String!, $repo: String!, $headRefName: String!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) {
repository(owner: $owner, name: $repo) {
defaultBranchRef {
name
}
pullRequests(headRefName: $headRefName, first: $per_page, orderBy: { field: CREATED_AT, direction: DESC }) {
totalCount
edges {
node {
...prWithReviews
}
}
}
}
`
if options.CurrentPR > 0 {
queryPrefix = `
query PullRequestStatus($owner: String!, $repo: String!, $number: Int!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) {
repository(owner: $owner, name: $repo) {
defaultBranchRef {
name
}
pullRequest(number: $number) {
...prWithReviews
}
}
`
}
query := fragments + queryPrefix + `
viewerCreated: search(query: $viewerQuery, type: ISSUE, first: $per_page) {
totalCount: issueCount
edges {
node {
...prWithReviews
}
}
}
reviewRequested: search(query: $reviewerQuery, type: ISSUE, first: $per_page) {
totalCount: issueCount
edges {
node {
...pr
}
}
}
}
`
currentUsername := options.Username
if currentUsername == "@me" && ghinstance.IsEnterprise(repo.RepoHost()) {
var err error
currentUsername, err = CurrentLoginName(client, repo.RepoHost())
if err != nil {
return nil, err
}
}
viewerQuery := fmt.Sprintf("repo:%s state:open is:pr author:%s", ghrepo.FullName(repo), currentUsername)
reviewerQuery := fmt.Sprintf("repo:%s state:open review-requested:%s", ghrepo.FullName(repo), currentUsername)
currentPRHeadRef := options.HeadRef
branchWithoutOwner := currentPRHeadRef
if idx := strings.Index(currentPRHeadRef, ":"); idx >= 0 {
branchWithoutOwner = currentPRHeadRef[idx+1:]
}
variables := map[string]interface{}{
"viewerQuery": viewerQuery,
"reviewerQuery": reviewerQuery,
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"headRefName": branchWithoutOwner,
"number": options.CurrentPR,
}
var resp response
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
return nil, err
}
var viewerCreated []PullRequest
for _, edge := range resp.ViewerCreated.Edges {
viewerCreated = append(viewerCreated, edge.Node)
}
var reviewRequested []PullRequest
for _, edge := range resp.ReviewRequested.Edges {
reviewRequested = append(reviewRequested, edge.Node)
}
var currentPR = resp.Repository.PullRequest
if currentPR == nil {
for _, edge := range resp.Repository.PullRequests.Edges {
if edge.Node.HeadLabel() == currentPRHeadRef {
currentPR = &edge.Node
break // Take the most recent PR for the current branch
}
}
}
payload := PullRequestsPayload{
ViewerCreated: PullRequestAndTotalCount{
PullRequests: viewerCreated,
TotalCount: resp.ViewerCreated.TotalCount,
},
ReviewRequested: PullRequestAndTotalCount{
PullRequests: reviewRequested,
TotalCount: resp.ReviewRequested.TotalCount,
},
CurrentPR: currentPR,
DefaultBranch: resp.Repository.DefaultBranchRef.Name,
}
return &payload, nil
}
func pullRequestFragment(httpClient *http.Client, hostname string) (string, error) {
cachedClient := NewCachedClient(httpClient, time.Hour*24)
prFeatures, err := determinePullRequestFeatures(cachedClient, hostname)
if err != nil {
return "", err
}
fields := []string{
"number", "title", "state", "url", "isDraft", "isCrossRepository",
"headRefName", "headRepositoryOwner", "mergeStateStatus",
}
if prFeatures.HasStatusCheckRollup {
fields = append(fields, "statusCheckRollup")
}
if prFeatures.HasBranchProtectionRule {
fields = append(fields, "requiresStrictStatusChecks")
}
var reviewFields []string
if prFeatures.HasReviewDecision {
reviewFields = append(reviewFields, "reviewDecision")
}
fragments := fmt.Sprintf(`
fragment pr on PullRequest {%s}
fragment prWithReviews on PullRequest {...pr,%s}
`, PullRequestGraphQL(fields), PullRequestGraphQL(reviewFields))
return fragments, nil
}
// CreatePullRequest creates a pull request in a GitHub repository
func CreatePullRequest(client *Client, repo *Repository, params map[string]interface{}) (*PullRequest, error) {
query := `
@ -630,8 +390,7 @@ func UpdatePullRequestReviews(client *Client, repo ghrepo.Interface, params gith
} `graphql:"requestReviews(input: $input)"`
}
variables := map[string]interface{}{"input": params}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.MutateNamed(context.Background(), "PullRequestUpdateRequestReviews", &mutation, variables)
err := client.Mutate(repo.RepoHost(), "PullRequestUpdateRequestReviews", &mutation, variables)
return err
}
@ -661,8 +420,8 @@ func PullRequestClose(httpClient *http.Client, repo ghrepo.Interface, prID strin
},
}
gql := graphQLClient(httpClient, repo.RepoHost())
return gql.MutateNamed(context.Background(), "PullRequestClose", &mutation, variables)
client := NewClientFromHTTP(httpClient)
return client.Mutate(repo.RepoHost(), "PullRequestClose", &mutation, variables)
}
func PullRequestReopen(httpClient *http.Client, repo ghrepo.Interface, prID string) error {
@ -680,8 +439,8 @@ func PullRequestReopen(httpClient *http.Client, repo ghrepo.Interface, prID stri
},
}
gql := graphQLClient(httpClient, repo.RepoHost())
return gql.MutateNamed(context.Background(), "PullRequestReopen", &mutation, variables)
client := NewClientFromHTTP(httpClient)
return client.Mutate(repo.RepoHost(), "PullRequestReopen", &mutation, variables)
}
func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
@ -699,11 +458,28 @@ func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) er
},
}
gql := graphQLClient(client.http, repo.RepoHost())
return gql.MutateNamed(context.Background(), "PullRequestReadyForReview", &mutation, variables)
return client.Mutate(repo.RepoHost(), "PullRequestReadyForReview", &mutation, variables)
}
func ConvertPullRequestToDraft(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
var mutation struct {
ConvertPullRequestToDraft struct {
PullRequest struct {
ID githubv4.ID
}
} `graphql:"convertPullRequestToDraft(input: $input)"`
}
variables := map[string]interface{}{
"input": githubv4.ConvertPullRequestToDraftInput{
PullRequestID: pr.ID,
},
}
return client.Mutate(repo.RepoHost(), "ConvertPullRequestToDraft", &mutation, variables)
}
func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) error {
path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch)
path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), url.PathEscape(branch))
return client.REST(repo.RepoHost(), "DELETE", path, nil, nil)
}

View file

@ -1,7 +1,6 @@
package api
import (
"context"
"time"
"github.com/cli/cli/v2/internal/ghrepo"
@ -31,6 +30,7 @@ type PullRequestReviews struct {
}
type PullRequestReview struct {
ID string `json:"id"`
Author Author `json:"author"`
AuthorAssociation string `json:"authorAssociation"`
Body string `json:"body"`
@ -39,6 +39,7 @@ type PullRequestReview struct {
ReactionGroups ReactionGroups `json:"reactionGroups"`
State string `json:"state"`
URL string `json:"url,omitempty"`
Commit Commit `json:"commit"`
}
func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error {
@ -65,8 +66,11 @@ func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *Pu
},
}
gql := graphQLClient(client.http, repo.RepoHost())
return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables)
return client.Mutate(repo.RepoHost(), "PullRequestReviewAdd", &mutation, variables)
}
func (prr PullRequestReview) Identifier() string {
return prr.ID
}
func (prr PullRequestReview) AuthorLogin() string {

View file

@ -4,7 +4,6 @@ import (
"encoding/json"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
@ -12,36 +11,44 @@ import (
func TestBranchDeleteRemote(t *testing.T) {
var tests = []struct {
name string
responseStatus int
responseBody string
expectError bool
name string
branch string
httpStubs func(*httpmock.Registry)
expectError bool
}{
{
name: "success",
responseStatus: 204,
responseBody: "",
expectError: false,
name: "success",
branch: "owner/branch#123",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/owner%2Fbranch%23123"),
httpmock.StatusStringResponse(204, ""))
},
expectError: false,
},
{
name: "error",
responseStatus: 500,
responseBody: `{"message": "oh no"}`,
expectError: true,
name: "error",
branch: "my-branch",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/my-branch"),
httpmock.StatusStringResponse(500, `{"message": "oh no"}`))
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
http := &httpmock.Registry{}
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/branch"),
httpmock.StatusStringResponse(tt.responseStatus, tt.responseBody))
if tt.httpStubs != nil {
tt.httpStubs(http)
}
client := NewClient(ReplaceTripper(http))
client := newTestClient(http)
repo, _ := ghrepo.FromFullName("OWNER/REPO")
err := BranchDeleteRemote(client, repo, "branch")
err := BranchDeleteRemote(client, repo, tt.branch)
if (err != nil) != tt.expectError {
t.Fatalf("unexpected result: %v", err)
}
@ -49,117 +56,6 @@ func TestBranchDeleteRemote(t *testing.T) {
}
}
func Test_determinePullRequestFeatures(t *testing.T) {
tests := []struct {
name string
hostname string
queryResponse map[string]string
wantPrFeatures pullRequestFeature
wantErr bool
}{
{
name: "github.com",
hostname: "github.com",
wantPrFeatures: pullRequestFeature{
HasReviewDecision: true,
HasStatusCheckRollup: true,
HasBranchProtectionRule: true,
},
wantErr: false,
},
{
name: "GHE empty response",
hostname: "git.my.org",
queryResponse: map[string]string{
`query PullRequest_fields\b`: `{"data": {}}`,
`query PullRequest_fields2\b`: `{"data": {}}`,
},
wantPrFeatures: pullRequestFeature{
HasReviewDecision: false,
HasStatusCheckRollup: false,
HasBranchProtectionRule: false,
},
wantErr: false,
},
{
name: "GHE has reviewDecision",
hostname: "git.my.org",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{ "data": { "PullRequest": { "fields": [
{"name": "foo"},
{"name": "reviewDecision"}
] } } }
`),
`query PullRequest_fields2\b`: `{"data": {}}`,
},
wantPrFeatures: pullRequestFeature{
HasReviewDecision: true,
HasStatusCheckRollup: false,
HasBranchProtectionRule: false,
},
wantErr: false,
},
{
name: "GHE has statusCheckRollup",
hostname: "git.my.org",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{ "data": { "Commit": { "fields": [
{"name": "foo"},
{"name": "statusCheckRollup"}
] } } }
`),
`query PullRequest_fields2\b`: `{"data": {}}`,
},
wantPrFeatures: pullRequestFeature{
HasReviewDecision: false,
HasStatusCheckRollup: true,
HasBranchProtectionRule: false,
},
wantErr: false,
},
{
name: "GHE has branchProtectionRule",
hostname: "git.my.org",
queryResponse: map[string]string{
`query PullRequest_fields\b`: `{"data": {}}`,
`query PullRequest_fields2\b`: heredoc.Doc(`
{ "data": { "Ref": { "fields": [
{"name": "foo"},
{"name": "branchProtectionRule"}
] } } }
`),
},
wantPrFeatures: pullRequestFeature{
HasReviewDecision: false,
HasStatusCheckRollup: false,
HasBranchProtectionRule: true,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeHTTP := &httpmock.Registry{}
httpClient := NewHTTPClient(ReplaceTripper(fakeHTTP))
for query, resp := range tt.queryResponse {
fakeHTTP.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
}
gotPrFeatures, err := determinePullRequestFeatures(httpClient, tt.hostname)
if tt.wantErr {
assert.Error(t, err)
return
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.wantPrFeatures, gotPrFeatures)
})
}
}
func Test_Logins(t *testing.T) {
rr := ReviewRequests{}
var tests = []struct {

View file

@ -2,8 +2,8 @@ package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@ -11,7 +11,10 @@ import (
"strings"
"time"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
ghAPI "github.com/cli/go-gh/pkg/api"
"github.com/shurcooL/githubv4"
)
@ -44,6 +47,7 @@ type Repository struct {
MergeCommitAllowed bool
SquashMergeAllowed bool
RebaseMergeAllowed bool
AutoMergeAllowed bool
ForkCount int
StargazerCount int
@ -66,6 +70,7 @@ type Repository struct {
IsArchived bool
IsEmpty bool
IsFork bool
ForkingAllowed bool
IsInOrganization bool
IsMirror bool
IsPrivate bool
@ -79,6 +84,7 @@ type Repository struct {
ViewerPermission string
ViewerPossibleCommitEmails []string
ViewerSubscription string
Visibility string
RepositoryTopics struct {
Nodes []struct {
@ -249,11 +255,13 @@ func FetchRepository(client *Client, repo ghrepo.Interface, fields []string) (*R
// The GraphQL API should have returned an error in case of a missing repository, but this isn't
// guaranteed to happen when an authentication token with insufficient permissions is being used.
if result.Repository == nil {
return nil, GraphQLErrorResponse{
Errors: []GraphQLError{{
Type: "NOT_FOUND",
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
}},
return nil, GraphQLError{
GQLError: ghAPI.GQLError{
Errors: []ghAPI.GQLErrorItem{{
Type: "NOT_FOUND",
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
}},
},
}
}
@ -300,11 +308,13 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
// The GraphQL API should have returned an error in case of a missing repository, but this isn't
// guaranteed to happen when an authentication token with insufficient permissions is being used.
if result.Repository == nil {
return nil, GraphQLErrorResponse{
Errors: []GraphQLError{{
Type: "NOT_FOUND",
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
}},
return nil, GraphQLError{
GQLError: ghAPI.GQLError{
Errors: []ghAPI.GQLErrorItem{{
Type: "NOT_FOUND",
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
}},
},
}
}
@ -354,8 +364,7 @@ func RepoParent(client *Client, repo ghrepo.Interface) (ghrepo.Interface, error)
"name": githubv4.String(repo.RepoName()),
}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.QueryNamed(context.Background(), "RepositoryFindParent", &query, variables)
err := client.Query(repo.RepoHost(), "RepositoryFindParent", &query, variables)
if err != nil {
return nil, err
}
@ -414,8 +423,8 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e
%s
}
`, strings.Join(queries, "")), nil, &graphqlResult)
graphqlError, isGraphQLError := err.(*GraphQLErrorResponse)
if isGraphQLError {
var graphqlError GraphQLError
if errors.As(err, &graphqlError) {
// If the only errors are that certain repositories are not found,
// continue processing this response instead of returning an error
tolerated := true
@ -492,13 +501,16 @@ type repositoryV3 struct {
}
// ForkRepo forks the repository on GitHub and returns the new repository
func ForkRepo(client *Client, repo ghrepo.Interface, org string) (*Repository, error) {
func ForkRepo(client *Client, repo ghrepo.Interface, org, newName string) (*Repository, error) {
path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo))
params := map[string]interface{}{}
if org != "" {
params["organization"] = org
}
if newName != "" {
params["name"] = newName
}
body := &bytes.Buffer{}
enc := json.NewEncoder(body)
@ -512,6 +524,45 @@ func ForkRepo(client *Client, repo ghrepo.Interface, org string) (*Repository, e
return nil, err
}
newRepo := &Repository{
ID: result.NodeID,
Name: result.Name,
CreatedAt: result.CreatedAt,
Owner: RepositoryOwner{
Login: result.Owner.Login,
},
ViewerPermission: "WRITE",
hostname: repo.RepoHost(),
}
// The GitHub API will happily return a HTTP 200 when attempting to fork own repo even though no forking
// actually took place. Ensure that we raise an error instead.
if ghrepo.IsSame(repo, newRepo) {
return newRepo, fmt.Errorf("%s cannot be forked", ghrepo.FullName(repo))
}
return newRepo, nil
}
// RenameRepo renames the repository on GitHub and returns the renamed repository
func RenameRepo(client *Client, repo ghrepo.Interface, newRepoName string) (*Repository, error) {
input := map[string]string{"name": newRepoName}
body := &bytes.Buffer{}
enc := json.NewEncoder(body)
if err := enc.Encode(input); err != nil {
return nil, err
}
path := fmt.Sprintf("%srepos/%s",
ghinstance.RESTPrefix(repo.RepoHost()),
ghrepo.FullName(repo))
result := repositoryV3{}
err := client.REST(repo.RepoHost(), "PATCH", path, body, &result)
if err != nil {
return nil, err
}
return &Repository{
ID: result.NodeID,
Name: result.Name,
@ -537,8 +588,7 @@ func LastCommit(client *Client, repo ghrepo.Interface) (*Commit, error) {
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()), "repo": githubv4.String(repo.RepoName()),
}
gql := graphQLClient(client.http, repo.RepoHost())
if err := gql.QueryNamed(context.Background(), "LastCommit", &responseData, variables); err != nil {
if err := client.Query(repo.RepoHost(), "LastCommit", &responseData, variables); err != nil {
return nil, err
}
return &responseData.Repository.DefaultBranchRef.Target.Commit, nil
@ -943,12 +993,10 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error)
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var projects []RepoProject
for {
var query responseData
err := gql.QueryNamed(context.Background(), "RepositoryProjectList", &query, variables)
err := client.Query(repo.RepoHost(), "RepositoryProjectList", &query, variables)
if err != nil {
return nil, err
}
@ -1014,12 +1062,10 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee,
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var users []RepoAssignee
for {
var query responseData
err := gql.QueryNamed(context.Background(), "RepositoryAssignableUsers", &query, variables)
err := client.Query(repo.RepoHost(), "RepositoryAssignableUsers", &query, variables)
if err != nil {
return nil, err
}
@ -1059,12 +1105,10 @@ func RepoLabels(client *Client, repo ghrepo.Interface) ([]RepoLabel, error) {
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var labels []RepoLabel
for {
var query responseData
err := gql.QueryNamed(context.Background(), "RepositoryLabelList", &query, variables)
err := client.Query(repo.RepoHost(), "RepositoryLabelList", &query, variables)
if err != nil {
return nil, err
}
@ -1117,12 +1161,10 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var milestones []RepoMilestone
for {
var query responseData
err := gql.QueryNamed(context.Background(), "RepositoryMilestoneList", &query, variables)
err := client.Query(repo.RepoHost(), "RepositoryMilestoneList", &query, variables)
if err != nil {
return nil, err
}
@ -1137,46 +1179,6 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo
return milestones, nil
}
func MilestoneByTitle(client *Client, repo ghrepo.Interface, state, title string) (*RepoMilestone, error) {
milestones, err := RepoMilestones(client, repo, state)
if err != nil {
return nil, err
}
for i := range milestones {
if strings.EqualFold(milestones[i].Title, title) {
return &milestones[i], nil
}
}
return nil, fmt.Errorf("no milestone found with title %q", title)
}
func MilestoneByNumber(client *Client, repo ghrepo.Interface, number int32) (*RepoMilestone, error) {
var query struct {
Repository struct {
Milestone *RepoMilestone `graphql:"milestone(number: $number)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
"number": githubv4.Int(number),
}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.QueryNamed(context.Background(), "RepositoryMilestoneByNumber", &query, variables)
if err != nil {
return nil, err
}
if query.Repository.Milestone == nil {
return nil, fmt.Errorf("no milestone found with number '%d'", number)
}
return query.Repository.Milestone, nil
}
func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string) ([]string, error) {
var paths []string
projects, err := RepoAndOrgProjects(client, repo)

View file

@ -18,7 +18,7 @@ func TestGitHubRepo_notFound(t *testing.T) {
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`{ "data": { "repository": null } }`))
client := NewClient(ReplaceTripper(httpReg))
client := newTestClient(httpReg)
repo, err := GitHubRepo(client, ghrepo.New("OWNER", "REPO"))
if err == nil {
t.Fatal("GitHubRepo did not return an error")
@ -33,7 +33,7 @@ func TestGitHubRepo_notFound(t *testing.T) {
func Test_RepoMetadata(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
client := newTestClient(http)
repo, _ := ghrepo.FromFullName("OWNER/REPO")
input := RepoMetadataInput{
@ -182,7 +182,7 @@ func Test_ProjectsToPaths(t *testing.T) {
func Test_ProjectNamesToPaths(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
client := newTestClient(http)
repo, _ := ghrepo.FromFullName("OWNER/REPO")
@ -221,7 +221,7 @@ func Test_ProjectNamesToPaths(t *testing.T) {
func Test_RepoResolveMetadataIDs(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
client := newTestClient(http)
repo, _ := ghrepo.FromFullName("OWNER/REPO")
input := RepoResolveInput{
@ -350,7 +350,7 @@ func Test_RepoMilestones(t *testing.T) {
query = buf.String()
return httpmock.StringResponse("{}")(req)
})
client := NewClient(ReplaceTripper(reg))
client := newTestClient(reg)
_, err := RepoMilestones(client, ghrepo.New("OWNER", "REPO"), tt.state)
if (err != nil) != tt.wantErr {

View file

@ -1,17 +1,12 @@
package api
import (
"context"
)
func CurrentLoginName(client *Client, hostname string) (string, error) {
var query struct {
Viewer struct {
Login string
}
}
gql := graphQLClient(client.http, hostname)
err := gql.QueryNamed(context.Background(), "UserCurrent", &query, nil)
err := client.Query(hostname, "UserCurrent", &query, nil)
return query.Viewer.Login, err
}
@ -21,7 +16,6 @@ func CurrentUserID(client *Client, hostname string) (string, error) {
ID string
}
}
gql := graphQLClient(client.http, hostname)
err := gql.QueryNamed(context.Background(), "UserCurrent", &query, nil)
err := client.Query(hostname, "UserCurrent", &query, nil)
return query.Viewer.ID, err
}

View file

@ -3,6 +3,8 @@ package api
import (
"fmt"
"strings"
"github.com/cli/cli/v2/pkg/set"
)
func squeeze(r rune) rune {
@ -21,6 +23,7 @@ func shortenQuery(q string) string {
var issueComments = shortenQuery(`
comments(first: 100) {
nodes {
id,
author{login},
authorAssociation,
body,
@ -28,7 +31,9 @@ var issueComments = shortenQuery(`
includesCreatedEdit,
isMinimized,
minimizedReason,
reactionGroups{content,users{totalCount}}
reactionGroups{content,users{totalCount}},
url,
viewerDidAuthor
},
pageInfo{hasNextPage,endCursor},
totalCount
@ -70,11 +75,13 @@ var prReviewRequests = shortenQuery(`
var prReviews = shortenQuery(`
reviews(first: 100) {
nodes {
id,
author{login},
authorAssociation,
submittedAt,
body,
state,
commit{oid},
reactionGroups{content,users{totalCount}}
}
pageInfo{hasNextPage,endCursor}
@ -82,6 +89,18 @@ var prReviews = shortenQuery(`
}
`)
var prLatestReviews = shortenQuery(`
latestReviews(first: 100) {
nodes {
author{login},
authorAssociation,
submittedAt,
body,
state
}
}
`)
var prFiles = shortenQuery(`
files(first: 100) {
nodes {
@ -129,10 +148,12 @@ func StatusCheckRollupGraphQL(after string) string {
...on StatusContext {
context,
state,
targetUrl
targetUrl,
createdAt
},
...on CheckRun {
name,
checkSuite{workflowRun{workflow{name}}},
status,
conclusion,
startedAt,
@ -148,6 +169,45 @@ func StatusCheckRollupGraphQL(after string) string {
}`), afterClause)
}
func RequiredStatusCheckRollupGraphQL(prID, after string) string {
var afterClause string
if after != "" {
afterClause = ",after:" + after
}
return fmt.Sprintf(shortenQuery(`
statusCheckRollup: commits(last: 1) {
nodes {
commit {
statusCheckRollup {
contexts(first:100%[1]s) {
nodes {
__typename
...on StatusContext {
context,
state,
targetUrl,
createdAt,
isRequired(pullRequestId: %[2]s)
},
...on CheckRun {
name,
checkSuite{workflowRun{workflow{name}}},
status,
conclusion,
startedAt,
completedAt,
detailsUrl,
isRequired(pullRequestId: %[2]s)
}
},
pageInfo{hasNextPage,endCursor}
}
}
}
}
}`), afterClause, prID)
}
var IssueFields = []string{
"assignees",
"author",
@ -176,10 +236,12 @@ var PullRequestFields = append(IssueFields,
"deletions",
"files",
"headRefName",
"headRefOid",
"headRepository",
"headRepositoryOwner",
"isCrossRepository",
"isDraft",
"latestReviews",
"maintainerCanModify",
"mergeable",
"mergeCommit",
@ -193,9 +255,8 @@ var PullRequestFields = append(IssueFields,
"statusCheckRollup",
)
// PullRequestGraphQL constructs a GraphQL query fragment for a set of pull request fields. Since GitHub
// pull requests are also technically issues, this function can be used to query issues as well.
func PullRequestGraphQL(fields []string) string {
// IssueGraphQL constructs a GraphQL query fragment for a set of issue fields.
func IssueGraphQL(fields []string) string {
var q []string
for _, field := range fields {
switch field {
@ -229,6 +290,8 @@ func PullRequestGraphQL(fields []string) string {
q = append(q, prReviewRequests)
case "reviews":
q = append(q, prReviews)
case "latestReviews":
q = append(q, prLatestReviews)
case "files":
q = append(q, prFiles)
case "commits":
@ -248,6 +311,16 @@ func PullRequestGraphQL(fields []string) string {
return strings.Join(q, ",")
}
// PullRequestGraphQL constructs a GraphQL query fragment for a set of pull request fields.
// It will try to sanitize the fields to just those available on pull request.
func PullRequestGraphQL(fields []string) string {
invalidFields := []string{"isPinned", "stateReason"}
s := set.NewStringSet()
s.AddValues(fields)
s.RemoveValues(invalidFields)
return IssueGraphQL(s.ToSlice())
}
var RepositoryFields = []string{
"id",
"name",

View file

@ -28,6 +28,11 @@ func TestPullRequestGraphQL(t *testing.T) {
fields: []string{"files"},
want: "files(first: 100) {nodes {additions,deletions,path}}",
},
{
name: "invalid fields",
fields: []string{"isPinned", "stateReason", "number"},
want: "number",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -37,3 +42,39 @@ func TestPullRequestGraphQL(t *testing.T) {
})
}
}
func TestIssueGraphQL(t *testing.T) {
tests := []struct {
name string
fields []string
want string
}{
{
name: "empty",
fields: []string(nil),
want: "",
},
{
name: "simple fields",
fields: []string{"number", "title"},
want: "number,title",
},
{
name: "fields with nested structures",
fields: []string{"author", "assignees"},
want: "author{login},assignees(first:100){nodes{id,login,name},totalCount}",
},
{
name: "compressed query",
fields: []string{"files"},
want: "files(first: 100) {nodes {additions,deletions,path}}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IssueGraphQL(tt.fields); got != tt.want {
t.Errorf("IssueGraphQL() = %v, want %v", got, tt.want)
}
})
}
}

38
build/windows/gh.wixproj Normal file
View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform Condition="'$(Platform)' == ''">x64</Platform>
<ProductVersion Condition="'$(ProductVersion)' == ''">0.1.0</ProductVersion>
<OutputName Condition="'$(OutputName)' == ''">$(MSBuildProjectName)</OutputName>
<OutputType>package</OutputType>
<RepoPath>$([MSBuild]::NormalizeDirectory($(MSBuildProjectDirectory)\..\..))</RepoPath>
<OutputPath Condition="'$(OutputPath)' == ''">$(RepoPath)bin\$(Platform)\</OutputPath>
<IntermediateOutputPath>$(RepoPath)bin\obj\$(Platform)\</IntermediateOutputPath>
<DefineConstants>
$(DefineConstants);
ProductVersion=$(ProductVersion);
</DefineConstants>
<SuppressIces Condition="'$(Platform)' == 'arm' Or '$(Platform)' == 'arm64'">ICE39</SuppressIces>
<DefineSolutionProperties>false</DefineSolutionProperties>
<WixTargetsPath Condition="'$(WixTargetsPath)' == ''">$(MSBuildExtensionsPath)\Microsoft\WiX\v3.x\Wix.targets</WixTargetsPath>
</PropertyGroup>
<ItemGroup>
<Compile Include="gh.wxs"/>
<Compile Include="ui.wxs"/>
</ItemGroup>
<ItemGroup>
<!-- Include directories containing both user-specified output and unzipped release for ease -->
<BindInputPaths Include="$(SourceDir)"/>
<BindInputPaths Include="$(SourceDir)\bin"/>
</ItemGroup>
<ItemGroup>
<WixExtension Include="WixUIExtension"/>
<WixExtension Include="WixUtilExtension"/>
</ItemGroup>
<Target Name="SetStepOutput" AfterTargets="Build" Condition="'$(GITHUB_ACTIONS)' != ''">
<!-- Make sure the correct target path is always set as the step output -->
<Message Importance="high" Text="::set-output name=msi::$(TargetPath)"/>
</Target>
<Import Project="$(WixTargetsPath)"/>
</Project>

77
build/windows/gh.wxs Normal file
View file

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<?ifndef ProductVersion?>
<?error ProductVersion property not defined?>
<?endif?>
<!-- Define a unique UpgradeCode per platform -->
<?if $(var.Platform) = "x64"?>
<?define InstallerVersion = "200"?>
<?define UpgradeCode = "8CFB9531-B959-4E1B-AA2E-4AF0FFCC4AF4"?>
<?define ProgramFilesFolder = "ProgramFiles64Folder"?>
<?elseif $(var.Platform) = "x86"?>
<?define InstallerVersion = "200"?>
<?define UpgradeCode = "767EC5D2-C8F0-4912-9901-45E21F59A284"?>
<?define ProgramFilesFolder = "ProgramFilesFolder"?>
<?elseif $(var.Platform) = "arm64"?>
<?define InstallerVersion = "500"?>
<?define UpgradeCode = "5D15E95C-F979-41B0-826C-C33C8CB5A7EB"?>
<?define ProgramFilesFolder = "ProgramFiles64Folder"?>
<?elseif $(var.Platform) = "arm"?>
<?define InstallerVersion = "500"?>
<?define UpgradeCode = "DDDE52AA-42DA-404B-9238-77DC86117CFF"?>
<?define ProgramFilesFolder = "ProgramFilesFolder"?>
<?endif?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product Id="*" Name="GitHub CLI" Version="$(var.ProductVersion)" Language="1033" Manufacturer="GitHub, Inc." UpgradeCode="$(var.UpgradeCode)">
<Package Compressed="yes" InstallerVersion="$(var.InstallerVersion)" InstallScope="perMachine"/>
<MediaTemplate EmbedCab="yes"/>
<!-- Remove older product(s) early but within the transaction -->
<MajorUpgrade Schedule="afterInstallInitialize" DowngradeErrorMessage="A newer version of !(bind.property.ProductName) is already installed."/>
<!-- Upgrade older x86 products -->
<Upgrade Id="7C0A5736-5B8E-4176-B350-613FA2D8A1B3">
<UpgradeVersion Maximum="$(var.ProductVersion)" Property="OLDERX86VERSIONDETECTED"/>
</Upgrade>
<Directory Id="TARGETDIR" Name="SourceDir">
<Directory Id="$(var.ProgramFilesFolder)" Name="Program Files">
<Directory Id="INSTALLDIR" Name="GitHub CLI"/>
</Directory>
</Directory>
<!-- Restore the INSTALLDIR if previously persisted to the registry -->
<Property Id="INSTALLDIR">
<RegistrySearch Id="InstallDir" Root="HKLM" Key="SOFTWARE\GitHub\CLI" Name="InstallDir" Type="directory"/>
</Property>
<Feature Id="DefaultFeature" ConfigurableDirectory="INSTALLDIR">
<!-- @Guid will be automatically and durably assigned based on key path -->
<Component Directory="INSTALLDIR">
<File Name="gh.exe"/>
<Environment Id="Path" Action="set" Name="PATH" Part="last" System="yes" Value="[INSTALLDIR]"/>
</Component>
<!-- Persist the INSTALLDIR and restore it in subsequent installs -->
<Component Directory="INSTALLDIR">
<RegistryValue Root="HKLM" Key="SOFTWARE\GitHub\CLI" Name="InstallDir" Type="string" Value="[INSTALLDIR]"/>
</Component>
<Component Id="OlderX86Env" Guid="50C15744-A674-404B-873C-6B58957E2A32" Directory="TARGETDIR" Win64="no">
<Condition><![CDATA[OLDERX86VERSIONDETECTED]]></Condition>
<!-- Clean up the old x86 package default directory from the user environment -->
<Environment Id="OlderX86Path" Action="remove" Name="PATH" Part="last" System="no" Value="[ProgramFilesFolder]GitHub CLI\"/>
</Component>
</Feature>
<!-- Broadcast environment variable changes -->
<CustomActionRef Id="WixBroadcastEnvironmentChange" />
<!-- Use customized WixUI_InstallDir that removes WixUI_LicenseAgreementDlg -->
<Property Id="WIXUI_INSTALLDIR" Value="INSTALLDIR"/>
<UIRef Id="GitHubCLI_InstallDir"/>
</Product>
</Wix>

54
build/windows/ui.wxs Normal file
View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Fragment>
<UI Id="GitHubCLI_InstallDir">
<TextStyle Id="WixUI_Font_Normal" FaceName="Tahoma" Size="8" />
<TextStyle Id="WixUI_Font_Bigger" FaceName="Tahoma" Size="12" />
<TextStyle Id="WixUI_Font_Title" FaceName="Tahoma" Size="9" Bold="yes" />
<Property Id="DefaultUIFont" Value="WixUI_Font_Normal" />
<Property Id="WixUI_Mode" Value="InstallDir" />
<DialogRef Id="BrowseDlg" />
<DialogRef Id="DiskCostDlg" />
<DialogRef Id="ErrorDlg" />
<DialogRef Id="FatalError" />
<DialogRef Id="FilesInUse" />
<DialogRef Id="MsiRMFilesInUse" />
<DialogRef Id="PrepareDlg" />
<DialogRef Id="ProgressDlg" />
<DialogRef Id="ResumeDlg" />
<DialogRef Id="UserExit" />
<Publish Dialog="BrowseDlg" Control="OK" Event="DoAction" Value="WixUIValidatePath" Order="3">1</Publish>
<Publish Dialog="BrowseDlg" Control="OK" Event="SpawnDialog" Value="InvalidDirDlg" Order="4"><![CDATA[NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"]]></Publish>
<Publish Dialog="ExitDialog" Control="Finish" Event="EndDialog" Value="Return" Order="999">1</Publish>
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="InstallDirDlg">NOT Installed</Publish>
<Publish Dialog="WelcomeDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg">Installed AND PATCH</Publish>
<Publish Dialog="InstallDirDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg">1</Publish>
<Publish Dialog="InstallDirDlg" Control="Next" Event="SetTargetPath" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
<Publish Dialog="InstallDirDlg" Control="Next" Event="DoAction" Value="WixUIValidatePath" Order="2">NOT WIXUI_DONTVALIDATEPATH</Publish>
<Publish Dialog="InstallDirDlg" Control="Next" Event="SpawnDialog" Value="InvalidDirDlg" Order="3"><![CDATA[NOT WIXUI_DONTVALIDATEPATH AND WIXUI_INSTALLDIR_VALID<>"1"]]></Publish>
<Publish Dialog="InstallDirDlg" Control="Next" Event="NewDialog" Value="VerifyReadyDlg" Order="4">WIXUI_DONTVALIDATEPATH OR WIXUI_INSTALLDIR_VALID="1"</Publish>
<Publish Dialog="InstallDirDlg" Control="ChangeFolder" Property="_BrowseProperty" Value="[WIXUI_INSTALLDIR]" Order="1">1</Publish>
<Publish Dialog="InstallDirDlg" Control="ChangeFolder" Event="SpawnDialog" Value="BrowseDlg" Order="2">1</Publish>
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="InstallDirDlg" Order="1">NOT Installed</Publish>
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="MaintenanceTypeDlg" Order="2">Installed AND NOT PATCH</Publish>
<Publish Dialog="VerifyReadyDlg" Control="Back" Event="NewDialog" Value="WelcomeDlg" Order="2">Installed AND PATCH</Publish>
<Publish Dialog="MaintenanceWelcomeDlg" Control="Next" Event="NewDialog" Value="MaintenanceTypeDlg">1</Publish>
<Publish Dialog="MaintenanceTypeDlg" Control="RepairButton" Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
<Publish Dialog="MaintenanceTypeDlg" Control="RemoveButton" Event="NewDialog" Value="VerifyReadyDlg">1</Publish>
<Publish Dialog="MaintenanceTypeDlg" Control="Back" Event="NewDialog" Value="MaintenanceWelcomeDlg">1</Publish>
<Property Id="ARPNOMODIFY" Value="1" />
</UI>
<UIRef Id="WixUI_Common" />
</Fragment>
</Wix>

View file

@ -40,9 +40,9 @@ func run(args []string) error {
return fmt.Errorf("error: --doc-path not set")
}
io, _, _, _ := iostreams.Test()
ios, _, _, _ := iostreams.Test()
rootCmd := root.NewCmdRoot(&cmdutil.Factory{
IOStreams: io,
IOStreams: ios,
Browser: &browser{},
}, "", "")
rootCmd.InitDefaultHelpCmd()

View file

@ -1,7 +1,7 @@
package main
import (
"io/ioutil"
"os"
"strings"
"testing"
)
@ -14,7 +14,7 @@ func Test_run(t *testing.T) {
t.Fatalf("got error: %v", err)
}
manPage, err := ioutil.ReadFile(dir + "/gh-issue-create.1")
manPage, err := os.ReadFile(dir + "/gh-issue-create.1")
if err != nil {
t.Fatalf("error reading `gh-issue-create.1`: %v", err)
}
@ -22,7 +22,7 @@ func Test_run(t *testing.T) {
t.Fatal("man page corrupted")
}
markdownPage, err := ioutil.ReadFile(dir + "/gh_issue_create.md")
markdownPage, err := os.ReadFile(dir + "/gh_issue_create.md")
if err != nil {
t.Fatalf("error reading `gh_issue_create.md`: %v", err)
}

View file

@ -13,20 +13,23 @@ import (
surveyCore "github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/build"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/internal/update"
"github.com/cli/cli/v2/pkg/cmd/alias/expand"
"github.com/cli/cli/v2/pkg/cmd/factory"
"github.com/cli/cli/v2/pkg/cmd/root"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/utils"
"github.com/cli/safeexec"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
"github.com/mgutz/ansi"
"github.com/spf13/cobra"
)
@ -57,16 +60,13 @@ func mainRun() exitCode {
updateMessageChan <- rel
}()
hasDebug := os.Getenv("DEBUG") != ""
hasDebug, _ := utils.IsDebugEnabled()
cmdFactory := factory.New(buildVersion)
stderr := cmdFactory.IOStreams.ErrOut
if spec := os.Getenv("GH_FORCE_TTY"); spec != "" {
cmdFactory.IOStreams.ForceTerminal(spec)
}
if !cmdFactory.IOStreams.ColorEnabled() {
surveyCore.DisableColor = true
ansi.DisableColors(true)
} else {
// override survey's poor choice of color
surveyCore.TemplateFuncsWithColor["color"] = func(style string) string {
@ -96,11 +96,6 @@ func mainRun() exitCode {
return exitError
}
// TODO: remove after FromFullName has been revisited
if host, err := cfg.DefaultHost(); err == nil {
ghrepo.SetDefaultHost(host)
}
expandedArgs := []string{}
if len(os.Args) > 0 {
expandedArgs = os.Args[1:]
@ -145,7 +140,7 @@ func mainRun() exitCode {
if errors.As(err, &execError) {
return exitCode(execError.ExitCode())
}
fmt.Fprintf(stderr, "failed to run external command: %s", err)
fmt.Fprintf(stderr, "failed to run external command: %s\n", err)
return exitError
}
@ -157,7 +152,7 @@ func mainRun() exitCode {
if errors.As(err, &execError) {
return exitCode(execError.ExitCode())
}
fmt.Fprintf(stderr, "failed to run extension: %s", err)
fmt.Fprintf(stderr, "failed to run extension: %s\n", err)
return exitError
} else if found {
return exitOK
@ -168,30 +163,44 @@ func mainRun() exitCode {
// provide completions for aliases and extensions
rootCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
var results []string
if aliases, err := cfg.Aliases(); err == nil {
for aliasName := range aliases.All() {
if strings.HasPrefix(aliasName, toComplete) {
results = append(results, aliasName)
aliases := cfg.Aliases()
for aliasName, aliasValue := range aliases.All() {
if strings.HasPrefix(aliasName, toComplete) {
var s string
if strings.HasPrefix(aliasValue, "!") {
s = fmt.Sprintf("%s\tShell alias", aliasName)
} else {
aliasValue = text.Truncate(80, aliasValue)
s = fmt.Sprintf("%s\tAlias for %s", aliasName, aliasValue)
}
results = append(results, s)
}
}
for _, ext := range cmdFactory.ExtensionManager.List(false) {
for _, ext := range cmdFactory.ExtensionManager.List() {
if strings.HasPrefix(ext.Name(), toComplete) {
results = append(results, ext.Name())
var s string
if ext.IsLocal() {
s = fmt.Sprintf("%s\tLocal extension gh-%s", ext.Name(), ext.Name())
} else {
path := ext.URL()
if u, err := git.ParseURL(ext.URL()); err == nil {
if r, err := ghrepo.FromURL(u); err == nil {
path = ghrepo.FullName(r)
}
}
s = fmt.Sprintf("%s\tExtension %s", ext.Name(), path)
}
results = append(results, s)
}
}
return results, cobra.ShellCompDirectiveNoFileComp
}
cs := cmdFactory.IOStreams.ColorScheme()
authError := errors.New("authError")
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
// require that the user is authenticated before running most commands
if cmdutil.IsAuthCheckEnabled(cmd) && !cmdutil.CheckAuth(cfg) {
fmt.Fprintln(stderr, cs.Bold("Welcome to GitHub CLI!"))
fmt.Fprintln(stderr)
fmt.Fprintln(stderr, "To authenticate, please run `gh auth login`.")
fmt.Fprint(stderr, authHelp())
return authError
}
@ -201,6 +210,8 @@ func mainRun() exitCode {
rootCmd.SetArgs(expandedArgs)
if cmd, err := rootCmd.ExecuteC(); err != nil {
var pagerPipeError *iostreams.ErrClosedPagerPipe
var noResultsError cmdutil.NoResultsError
if err == cmdutil.SilentError {
return exitError
} else if cmdutil.IsUserCancellation(err) {
@ -211,6 +222,15 @@ func mainRun() exitCode {
return exitCancel
} else if errors.Is(err, authError) {
return exitAuth
} else if errors.As(err, &pagerPipeError) {
// ignore the error raised when piping to a closed pager
return exitOK
} else if errors.As(err, &noResultsError) {
if cmdFactory.IOStreams.IsStdoutTTY() {
fmt.Fprintln(stderr, noResultsError.Error())
}
// no results is not a command failure
return exitOK
}
printError(stderr, err, cmd, hasDebug)
@ -224,8 +244,9 @@ func mainRun() exitCode {
var httpErr api.HTTPError
if errors.As(err, &httpErr) && httpErr.StatusCode == 401 {
fmt.Fprintln(stderr, "Try authenticating with: gh auth login")
} else if strings.Contains(err.Error(), "Resource protected by organization SAML enforcement") {
fmt.Fprintln(stderr, "Try re-authenticating with: gh auth refresh")
} else if u := factory.SSOURL(); u != "" {
// handles organization SAML enforcement error
fmt.Fprintf(stderr, "Authorize in your web browser: %s\n", u)
} else if msg := httpErr.ScopesSuggestion(); msg != "" {
fmt.Fprintln(stderr, msg)
}
@ -245,10 +266,10 @@ func mainRun() exitCode {
}
fmt.Fprintf(stderr, "\n\n%s %s → %s\n",
ansi.Color("A new release of gh is available:", "yellow"),
ansi.Color(buildVersion, "cyan"),
ansi.Color(newRelease.Version, "cyan"))
ansi.Color(strings.TrimPrefix(buildVersion, "v"), "cyan"),
ansi.Color(strings.TrimPrefix(newRelease.Version, "v"), "cyan"))
if isHomebrew {
fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew update && brew upgrade gh")
fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew upgrade gh")
}
fmt.Fprintf(stderr, "%s\n\n",
ansi.Color(newRelease.URL, "yellow"))
@ -285,6 +306,27 @@ func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
}
}
func authHelp() string {
if os.Getenv("GITHUB_ACTIONS") == "true" {
return heredoc.Doc(`
gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable. Example:
env:
GH_TOKEN: ${{ github.token }}
`)
}
if os.Getenv("CI") != "" {
return heredoc.Doc(`
gh: To use GitHub CLI in automation, set the GH_TOKEN environment variable.
`)
}
return heredoc.Doc(`
To get started with GitHub CLI, please run: gh auth login
Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.
`)
}
func shouldCheckForUpdate() bool {
if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" {
return false
@ -292,7 +334,11 @@ func shouldCheckForUpdate() bool {
if os.Getenv("CODESPACES") != "" {
return false
}
return updaterEnabled != "" && !isCI() && utils.IsTerminal(os.Stdout) && utils.IsTerminal(os.Stderr)
return updaterEnabled != "" && !isCI() && isTerminal(os.Stdout) && isTerminal(os.Stderr)
}
func isTerminal(f *os.File) bool {
return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
}
// based on https://github.com/watson/ci-info/blob/HEAD/index.js
@ -306,44 +352,19 @@ func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) {
if !shouldCheckForUpdate() {
return nil, nil
}
client, err := basicClient(currentVersion)
httpClient, err := api.NewHTTPClient(api.HTTPClientOptions{
AppVersion: currentVersion,
Log: os.Stderr,
})
if err != nil {
return nil, err
}
client := api.NewClientFromHTTP(httpClient)
repo := updaterEnabled
stateFilePath := filepath.Join(config.StateDir(), "state.yml")
return update.CheckForUpdate(client, stateFilePath, repo, currentVersion)
}
// BasicClient returns an API client for github.com only that borrows from but
// does not depend on user configuration
func basicClient(currentVersion string) (*api.Client, error) {
var opts []api.ClientOption
if verbose := os.Getenv("DEBUG"); verbose != "" {
opts = append(opts, apiVerboseLog())
}
opts = append(opts, api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", currentVersion)))
token, _ := config.AuthTokenFromEnv(ghinstance.Default())
if token == "" {
if c, err := config.ParseDefaultConfig(); err == nil {
token, _ = c.Get(ghinstance.Default(), "oauth_token")
}
}
if token != "" {
opts = append(opts, api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
}
return api.NewClient(opts...), nil
}
func apiVerboseLog() api.ClientOption {
logTraffic := strings.Contains(os.Getenv("DEBUG"), "api")
colorize := utils.IsTerminal(os.Stderr)
return api.VerboseLog(colorable.NewColorable(os.Stderr), logTraffic, colorize)
}
func isRecentRelease(publishedAt time.Time) bool {
return !publishedAt.IsZero() && time.Since(publishedAt) < time.Hour*24
}

View file

@ -2,15 +2,14 @@
package context
import (
"context"
"errors"
"sort"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/prompt"
)
// cap the number of git remotes looked up, since the user might have an
@ -59,7 +58,11 @@ type ResolvedRemotes struct {
apiClient *api.Client
}
func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, error) {
type iprompter interface {
Select(string, string, []string) (int, error)
}
func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams, p iprompter) (ghrepo.Interface, error) {
if r.baseOverride != nil {
return r.baseOverride, nil
}
@ -116,13 +119,14 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, e
baseName := repoNames[0]
if len(repoNames) > 1 {
err := prompt.SurveyAskOne(&survey.Select{
Message: "Which should be the base repository (used for e.g. querying issues) for this directory?",
Options: repoNames,
}, &baseName)
// hide the spinner in case a command started the progress indicator before base repo was fully
// resolved, e.g. in `gh issue view`
io.StopProgressIndicator()
selected, err := p.Select("Which should be the base repository (used for e.g. querying issues) for this directory?", "", repoNames)
if err != nil {
return nil, err
}
baseName = repoNames[selected]
}
// determine corresponding git remote
@ -135,7 +139,8 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, e
}
// cache the result to git config
err := git.SetRemoteResolution(remote.Name, resolution)
c := &git.Client{}
err := c.SetRemoteResolution(context.Background(), remote.Name, resolution)
return selectedRepo, err
}

View file

@ -89,15 +89,18 @@ func (r Remote) RepoHost() string {
return r.Repo.RepoHost()
}
// TODO: accept an interface instead of git.RemoteSet
func TranslateRemotes(gitRemotes git.RemoteSet, urlTranslate func(*url.URL) *url.URL) (remotes Remotes) {
type Translator interface {
Translate(*url.URL) *url.URL
}
func TranslateRemotes(gitRemotes git.RemoteSet, translator Translator) (remotes Remotes) {
for _, r := range gitRemotes {
var repo ghrepo.Interface
if r.FetchURL != nil {
repo, _ = ghrepo.FromURL(urlTranslate(r.FetchURL))
repo, _ = ghrepo.FromURL(translator.Translate(r.FetchURL))
}
if r.PushURL != nil && repo == nil {
repo, _ = ghrepo.FromURL(urlTranslate(r.PushURL))
repo, _ = ghrepo.FromURL(translator.Translate(r.PushURL))
}
if repo == nil {
continue

View file

@ -28,6 +28,12 @@ func Test_Remotes_FindByName(t *testing.T) {
assert.Error(t, err, "no GitHub remotes found")
}
type identityTranslator struct{}
func (it identityTranslator) Translate(u *url.URL) *url.URL {
return u
}
func Test_translateRemotes(t *testing.T) {
publicURL, _ := url.Parse("https://github.com/monalisa/hello")
originURL, _ := url.Parse("http://example.com/repo")
@ -43,10 +49,7 @@ func Test_translateRemotes(t *testing.T) {
},
}
identityURL := func(u *url.URL) *url.URL {
return u
}
result := TranslateRemotes(gitRemotes, identityURL)
result := TranslateRemotes(gitRemotes, identityTranslator{})
if len(result) != 1 {
t.Errorf("got %d results", len(result))

View file

@ -14,12 +14,17 @@ our release schedule.
Install:
```bash
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh
type -p curl >/dev/null || sudo apt install curl -y
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh -y
```
> **Note**
> We were recently forced to change our GPG signing key. If you've previously downloaded the `githubcli-archive-keyring.gpg` file, you should re-download it again per above instructions. If you are using a keyserver to download the key, the ID of the new key is `23F3D4EA75716059`.
Upgrade:
```bash
@ -29,19 +34,44 @@ sudo apt install gh
### Fedora, CentOS, Red Hat Enterprise Linux (dnf)
Install:
Install from our package repository for immediate access to latest releases:
```bash
sudo dnf install 'dnf-command(config-manager)'
sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo
sudo dnf install gh
```
Alternatively, install from the [community repository](https://packages.fedoraproject.org/pkgs/gh/gh/):
```bash
sudo dnf install gh
```
Upgrade:
```bash
sudo dnf update gh
```
### Amazon Linux 2 (yum)
Install using our package repository for immediate access to latest releases:
```bash
sudo yum-config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo
sudo yum install gh
```
> **Note**
> We were recently forced to change our GPG signing key. If you've added the repository previously and now you're getting a GPG signing key error, disable the repository first with `sudo yum-config-manager --disable gh-cli` and add it again with `sudo yum-config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo`.
Upgrade:
```bash
sudo yum update gh
```
### openSUSE/SUSE Linux (zypper)
Install:
@ -179,6 +209,30 @@ openSUSE Tumbleweed users can install from the [official distribution repo](http
sudo zypper in gh
```
### Alpine Linux
Alpine Linux users can install from the [stable releases' community package repository](https://pkgs.alpinelinux.org/packages?name=github-cli&branch=v3.15).
```bash
apk add github-cli
```
Users wanting the latest version of the CLI without waiting to be backported into the stable release they're using should use the edge release's
community repo through this method below, without mixing packages from stable and unstable repos.[^1]
```bash
echo "@community http://dl-cdn.alpinelinux.org/alpine/edge/community" >> /etc/apk/repositories
apk add github-cli@community
```
### Void Linux
Void Linux users can install from the [official distribution repo](https://voidlinux.org/packages/?arch=x86_64&q=github-cli):
```bash
sudo xbps-install github-cli
```
[releases page]: https://github.com/cli/cli/releases/latest
[arch linux repo]: https://www.archlinux.org/packages/community/x86_64/github-cli
[arch linux aur]: https://aur.archlinux.org/packages/github-cli-git
[^1]: https://wiki.alpinelinux.org/wiki/Package_management#Repository_pinning

View file

@ -1,6 +1,6 @@
# Installation from source
1. Verify that you have Go 1.16+ installed
1. Verify that you have Go 1.18+ installed
```sh
$ go version
@ -19,14 +19,14 @@
#### Unix-like systems
```sh
# installs to '/usr/local' by default; sudo may be required
# installs to '/usr/local' by default; sudo may be required, or sudo -E for configured go environments
$ make install
# or, install to a different location
$ make install prefix=/path/to/gh
```
#### Windows
#### Windows
```pwsh
# build the `bin\gh.exe` binary
> go run script\build.go

View file

@ -6,31 +6,23 @@ triage role. The initial expectation is that the person in the role for the week
## Expectations for incoming issues
All incoming issues need either an **enhancement**, **bug**, or **docs** label.
All incoming issues need either an `enhancement`, `bug`, or `docs` label.
To be considered triaged, **enhancement** issues require at least one of the following additional labels:
To be considered triaged, `enhancement` issues require at least one of the following additional labels:
- **core**: work reserved for the core CLI team
- **help wanted**: work that we would accept contributions for
- **needs-design**: work that requires input from a UX designer before it can move forward
- **needs-investigation**: work that requires a mystery be solved by the core team before it can move forward
- **needs-user-input**: work that requires more information from the reporter before it can move forward
- `core`: reserved for the core CLI team
- `help wanted`: signal that we are accepting contributions for this
- `discuss`: add to our team's queue to discuss during a sync
- `needs-investigation`: work that requires a mystery be solved by the core team before it can move forward
- `needs-user-input`: we need more information from our users before the task can move forward
To be considered triaged, **bug** issues require a severity label: one of **p1**, **p2**, or **p3**
For a more detailed breakdown of **how** to triage an issue, see the _Issue triage flowchart_ below.
To be considered triaged, `bug` issues require a severity label: one of `p1`, `p2`, or `p3`
## Expectations for community pull requests
To be considered triaged, incoming pull requests should:
- be checked for a corresponding **help wanted** issue
- be checked for basic quality: are the builds passing? have tests been added?
- be checked for redundancy: is there already a PR dealing with this?
Once a pull request has been triaged, it should be moved to the **Needs Review** column of the project board.
For a more detailed breakdown of **how** to triage an issue, see the _PR triage flowchart_ below.
All incoming pull requests are assigned to one of the engineers for review on a round-robin basis.
The person in a triage role for a week could take a glance at these pull requests, mostly to see whether
the changeset is feasible and to allow the associated CI run for new contributors.
## Issue triage flowchart
@ -46,30 +38,17 @@ For a more detailed breakdown of **how** to triage an issue, see the _PR triage
- add `help wanted` label
- consider adding `good first issue` label
- do we want to do it?
- comment acknowledging it
- comment acknowledging that
- add `core` label
- add to project TODO column if this is something that should ship soon
- add to the project TODO column if this is something that should ship soon
- is it intriguing, but requires discussion?
- label `needs-design` if design input is needed, ping
- label `discuss`
- label `needs-investigation` if engineering research is required before action can be taken
- does it need more info from the issue author?
- ask the user for details
- add `needs-user-input` label
- is it a usage/support question?
- offer some instructions/workaround and close
## Pull request triage flowchart
- can it be closed outright?
- e.g. spam/junk
- close
- do we not want to do it?
- comment and close
- is it intriguing, but requires discussion and there is no referenced issue?
- request an issue
- close
- is it something we want to include?
- add to `needs review` column
- consider converting the Issue to a Discussion
## Weekly PR audit

592
git/client.go Normal file
View file

@ -0,0 +1,592 @@
package git
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/url"
"os/exec"
"path"
"regexp"
"runtime"
"sort"
"strings"
"sync"
"github.com/cli/safeexec"
)
var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
type Client struct {
GhPath string
RepoDir string
GitPath string
Stderr io.Writer
Stdin io.Reader
Stdout io.Writer
commandContext commandCtx
mu sync.Mutex
}
func (c *Client) Command(ctx context.Context, args ...string) (*Command, error) {
if c.RepoDir != "" {
args = append([]string{"-C", c.RepoDir}, args...)
}
commandContext := exec.CommandContext
if c.commandContext != nil {
commandContext = c.commandContext
}
var err error
c.mu.Lock()
if c.GitPath == "" {
c.GitPath, err = resolveGitPath()
}
c.mu.Unlock()
if err != nil {
return nil, err
}
cmd := commandContext(ctx, c.GitPath, args...)
cmd.Stderr = c.Stderr
cmd.Stdin = c.Stdin
cmd.Stdout = c.Stdout
return &Command{cmd}, nil
}
// AuthenticatedCommand is a wrapper around Command that included configuration to use gh
// as the credential helper for git.
func (c *Client) AuthenticatedCommand(ctx context.Context, args ...string) (*Command, error) {
preArgs := []string{"-c", "credential.helper="}
if c.GhPath == "" {
// Assumes that gh is in PATH.
c.GhPath = "gh"
}
credHelper := fmt.Sprintf("!%q auth git-credential", c.GhPath)
preArgs = append(preArgs, "-c", fmt.Sprintf("credential.helper=%s", credHelper))
args = append(preArgs, args...)
return c.Command(ctx, args...)
}
func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) {
remoteArgs := []string{"remote", "-v"}
remoteCmd, err := c.Command(ctx, remoteArgs...)
if err != nil {
return nil, err
}
remoteOut, remoteErr := remoteCmd.Output()
if remoteErr != nil {
return nil, remoteErr
}
configArgs := []string{"config", "--get-regexp", `^remote\..*\.gh-resolved$`}
configCmd, err := c.Command(ctx, configArgs...)
if err != nil {
return nil, err
}
configOut, configErr := configCmd.Output()
if configErr != nil {
// Ignore exit code 1 as it means there are no resolved remotes.
var gitErr *GitError
if ok := errors.As(configErr, &gitErr); ok && gitErr.ExitCode != 1 {
return nil, gitErr
}
}
remotes := parseRemotes(outputLines(remoteOut))
populateResolvedRemotes(remotes, outputLines(configOut))
sort.Sort(remotes)
return remotes, nil
}
func (c *Client) UpdateRemoteURL(ctx context.Context, name, url string) error {
args := []string{"remote", "set-url", name, url}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func (c *Client) SetRemoteResolution(ctx context.Context, name, resolution string) error {
args := []string{"config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
// CurrentBranch reads the checked-out branch for the git repository.
func (c *Client) CurrentBranch(ctx context.Context) (string, error) {
args := []string{"symbolic-ref", "--quiet", "HEAD"}
cmd, err := c.Command(ctx, args...)
if err != nil {
return "", err
}
out, err := cmd.Output()
if err != nil {
var gitErr *GitError
if ok := errors.As(err, &gitErr); ok && len(gitErr.Stderr) == 0 {
gitErr.Stderr = "not on any branch"
return "", gitErr
}
return "", err
}
branch := firstLine(out)
return strings.TrimPrefix(branch, "refs/heads/"), nil
}
// ShowRefs resolves fully-qualified refs to commit hashes.
func (c *Client) ShowRefs(ctx context.Context, refs []string) ([]Ref, error) {
args := append([]string{"show-ref", "--verify", "--"}, refs...)
cmd, err := c.Command(ctx, args...)
if err != nil {
return nil, err
}
// This functionality relies on parsing output from the git command despite
// an error status being returned from git.
out, err := cmd.Output()
var verified []Ref
for _, line := range outputLines(out) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
verified = append(verified, Ref{
Hash: parts[0],
Name: parts[1],
})
}
return verified, err
}
func (c *Client) Config(ctx context.Context, name string) (string, error) {
args := []string{"config", name}
cmd, err := c.Command(ctx, args...)
if err != nil {
return "", err
}
out, err := cmd.Output()
if err != nil {
var gitErr *GitError
if ok := errors.As(err, &gitErr); ok && gitErr.ExitCode == 1 {
gitErr.Stderr = fmt.Sprintf("unknown config key %s", name)
return "", gitErr
}
return "", err
}
return firstLine(out), nil
}
func (c *Client) UncommittedChangeCount(ctx context.Context) (int, error) {
args := []string{"status", "--porcelain"}
cmd, err := c.Command(ctx, args...)
if err != nil {
return 0, err
}
out, err := cmd.Output()
if err != nil {
return 0, err
}
lines := strings.Split(string(out), "\n")
count := 0
for _, l := range lines {
if l != "" {
count++
}
}
return count, nil
}
func (c *Client) Commits(ctx context.Context, baseRef, headRef string) ([]*Commit, error) {
args := []string{"-c", "log.ShowSignature=false", "log", "--pretty=format:%H,%s", "--cherry", fmt.Sprintf("%s...%s", baseRef, headRef)}
cmd, err := c.Command(ctx, args...)
if err != nil {
return nil, err
}
out, err := cmd.Output()
if err != nil {
return nil, err
}
commits := []*Commit{}
sha := 0
title := 1
for _, line := range outputLines(out) {
split := strings.SplitN(line, ",", 2)
if len(split) != 2 {
continue
}
commits = append(commits, &Commit{
Sha: split[sha],
Title: split[title],
})
}
if len(commits) == 0 {
return nil, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef)
}
return commits, nil
}
func (c *Client) LastCommit(ctx context.Context) (*Commit, error) {
output, err := c.lookupCommit(ctx, "HEAD", "%H,%s")
if err != nil {
return nil, err
}
idx := bytes.IndexByte(output, ',')
return &Commit{
Sha: string(output[0:idx]),
Title: strings.TrimSpace(string(output[idx+1:])),
}, nil
}
func (c *Client) CommitBody(ctx context.Context, sha string) (string, error) {
output, err := c.lookupCommit(ctx, sha, "%b")
return string(output), err
}
func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, error) {
args := []string{"-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:" + format, sha}
cmd, err := c.Command(ctx, args...)
if err != nil {
return nil, err
}
out, err := cmd.Output()
if err != nil {
return nil, err
}
return out, nil
}
// ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config.
func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg BranchConfig) {
prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch))
args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix)}
cmd, err := c.Command(ctx, args...)
if err != nil {
return
}
out, err := cmd.Output()
if err != nil {
return
}
for _, line := range outputLines(out) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
keys := strings.Split(parts[0], ".")
switch keys[len(keys)-1] {
case "remote":
if strings.Contains(parts[1], ":") {
u, err := ParseURL(parts[1])
if err != nil {
continue
}
cfg.RemoteURL = u
} else if !isFilesystemPath(parts[1]) {
cfg.RemoteName = parts[1]
}
case "merge":
cfg.MergeRef = parts[1]
}
}
return
}
func (c *Client) DeleteLocalBranch(ctx context.Context, branch string) error {
args := []string{"branch", "-D", branch}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func (c *Client) CheckoutBranch(ctx context.Context, branch string) error {
args := []string{"checkout", branch}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func (c *Client) CheckoutNewBranch(ctx context.Context, remoteName, branch string) error {
track := fmt.Sprintf("%s/%s", remoteName, branch)
args := []string{"checkout", "-b", branch, "--track", track}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool {
_, err := c.revParse(ctx, "--verify", "refs/heads/"+branch)
return err == nil
}
// ToplevelDir returns the top-level directory path of the current repository.
func (c *Client) ToplevelDir(ctx context.Context) (string, error) {
out, err := c.revParse(ctx, "--show-toplevel")
if err != nil {
return "", err
}
return firstLine(out), nil
}
func (c *Client) GitDir(ctx context.Context) (string, error) {
out, err := c.revParse(ctx, "--git-dir")
if err != nil {
return "", err
}
return firstLine(out), nil
}
// Show current directory relative to the top-level directory of repository.
func (c *Client) PathFromRoot(ctx context.Context) string {
out, err := c.revParse(ctx, "--show-prefix")
if err != nil {
return ""
}
if path := firstLine(out); path != "" {
return path[:len(path)-1]
}
return ""
}
func (c *Client) revParse(ctx context.Context, args ...string) ([]byte, error) {
args = append([]string{"rev-parse"}, args...)
cmd, err := c.Command(ctx, args...)
if err != nil {
return nil, err
}
return cmd.Output()
}
// Below are commands that make network calls and need authentication credentials supplied from gh.
func (c *Client) Fetch(ctx context.Context, remote string, refspec string, mods ...CommandModifier) error {
args := []string{"fetch", remote, refspec}
cmd, err := c.AuthenticatedCommand(ctx, args...)
if err != nil {
return err
}
for _, mod := range mods {
mod(cmd)
}
return cmd.Run()
}
func (c *Client) Pull(ctx context.Context, remote, branch string, mods ...CommandModifier) error {
args := []string{"pull", "--ff-only"}
if remote != "" && branch != "" {
args = append(args, remote, branch)
}
cmd, err := c.AuthenticatedCommand(ctx, args...)
if err != nil {
return err
}
for _, mod := range mods {
mod(cmd)
}
return cmd.Run()
}
func (c *Client) Push(ctx context.Context, remote string, ref string, mods ...CommandModifier) error {
args := []string{"push", "--set-upstream", remote, ref}
cmd, err := c.AuthenticatedCommand(ctx, args...)
if err != nil {
return err
}
for _, mod := range mods {
mod(cmd)
}
return cmd.Run()
}
func (c *Client) Clone(ctx context.Context, cloneURL string, args []string, mods ...CommandModifier) (string, error) {
cloneArgs, target := parseCloneArgs(args)
cloneArgs = append(cloneArgs, cloneURL)
// If the args contain an explicit target, pass it to clone otherwise,
// parse the URL to determine where git cloned it to so we can return it.
if target != "" {
cloneArgs = append(cloneArgs, target)
} else {
target = path.Base(strings.TrimSuffix(cloneURL, ".git"))
}
cloneArgs = append([]string{"clone"}, cloneArgs...)
cmd, err := c.AuthenticatedCommand(ctx, cloneArgs...)
if err != nil {
return "", err
}
for _, mod := range mods {
mod(cmd)
}
err = cmd.Run()
if err != nil {
return "", err
}
return target, nil
}
func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string, mods ...CommandModifier) (*Remote, error) {
args := []string{"remote", "add"}
for _, branch := range trackingBranches {
args = append(args, "-t", branch)
}
args = append(args, "-f", name, urlStr)
cmd, err := c.AuthenticatedCommand(ctx, args...)
if err != nil {
return nil, err
}
for _, mod := range mods {
mod(cmd)
}
if _, err := cmd.Output(); err != nil {
return nil, err
}
var urlParsed *url.URL
if strings.HasPrefix(urlStr, "https") {
urlParsed, err = url.Parse(urlStr)
if err != nil {
return nil, err
}
} else {
urlParsed, err = ParseURL(urlStr)
if err != nil {
return nil, err
}
}
remote := &Remote{
Name: name,
FetchURL: urlParsed,
PushURL: urlParsed,
}
return remote, nil
}
func resolveGitPath() (string, error) {
path, err := safeexec.LookPath("git")
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
programName := "git"
if runtime.GOOS == "windows" {
programName = "Git for Windows"
}
return "", &NotInstalled{
message: fmt.Sprintf("unable to find git executable in PATH; please install %s before retrying", programName),
err: err,
}
}
return "", err
}
return path, nil
}
func isFilesystemPath(p string) bool {
return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/")
}
func outputLines(output []byte) []string {
lines := strings.TrimSuffix(string(output), "\n")
return strings.Split(lines, "\n")
}
func firstLine(output []byte) string {
if i := bytes.IndexAny(output, "\n"); i >= 0 {
return string(output)[0:i]
}
return string(output)
}
func parseCloneArgs(extraArgs []string) (args []string, target string) {
args = extraArgs
if len(args) > 0 {
if !strings.HasPrefix(args[0], "-") {
target, args = args[0], args[1:]
}
}
return
}
func parseRemotes(remotesStr []string) RemoteSet {
remotes := RemoteSet{}
for _, r := range remotesStr {
match := remoteRE.FindStringSubmatch(r)
if match == nil {
continue
}
name := strings.TrimSpace(match[1])
urlStr := strings.TrimSpace(match[2])
urlType := strings.TrimSpace(match[3])
url, err := ParseURL(urlStr)
if err != nil {
continue
}
var rem *Remote
if len(remotes) > 0 {
rem = remotes[len(remotes)-1]
if name != rem.Name {
rem = nil
}
}
if rem == nil {
rem = &Remote{Name: name}
remotes = append(remotes, rem)
}
switch urlType {
case "fetch":
rem.FetchURL = url
case "push":
rem.PushURL = url
}
}
return remotes
}
func populateResolvedRemotes(remotes RemoteSet, resolved []string) {
for _, l := range resolved {
parts := strings.SplitN(l, " ", 2)
if len(parts) < 2 {
continue
}
rp := strings.SplitN(parts[0], ".", 3)
if len(rp) < 2 {
continue
}
name := rp[1]
for _, r := range remotes {
if r.Name == name {
r.Resolved = parts[1]
break
}
}
}
}

1170
git/client_test.go Normal file

File diff suppressed because it is too large Load diff

100
git/command.go Normal file
View file

@ -0,0 +1,100 @@
package git
import (
"bytes"
"context"
"errors"
"io"
"os/exec"
"github.com/cli/cli/v2/internal/run"
)
type commandCtx = func(ctx context.Context, name string, args ...string) *exec.Cmd
type Command struct {
*exec.Cmd
}
func (gc *Command) Run() error {
stderr := &bytes.Buffer{}
if gc.Cmd.Stderr == nil {
gc.Cmd.Stderr = stderr
}
// This is a hack in order to not break the hundreds of
// existing tests that rely on `run.PrepareCmd` to be invoked.
err := run.PrepareCmd(gc.Cmd).Run()
if err != nil {
ge := GitError{err: err, Stderr: stderr.String()}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
ge.ExitCode = exitError.ExitCode()
}
return &ge
}
return nil
}
func (gc *Command) Output() ([]byte, error) {
gc.Stdout = nil
gc.Stderr = nil
// This is a hack in order to not break the hundreds of
// existing tests that rely on `run.PrepareCmd` to be invoked.
out, err := run.PrepareCmd(gc.Cmd).Output()
if err != nil {
ge := GitError{err: err}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
ge.Stderr = string(exitError.Stderr)
ge.ExitCode = exitError.ExitCode()
}
err = &ge
}
return out, err
}
func (gc *Command) setRepoDir(repoDir string) {
for i, arg := range gc.Args {
if arg == "-C" {
gc.Args[i+1] = repoDir
return
}
}
// Handle "--" invocations for testing purposes.
var index int
for i, arg := range gc.Args {
if arg == "--" {
index = i + 1
}
}
gc.Args = append(gc.Args[:index+3], gc.Args[index+1:]...)
gc.Args[index+1] = "-C"
gc.Args[index+2] = repoDir
}
// Allow individual commands to be modified from the default client options.
type CommandModifier func(*Command)
func WithStderr(stderr io.Writer) CommandModifier {
return func(gc *Command) {
gc.Stderr = stderr
}
}
func WithStdout(stdout io.Writer) CommandModifier {
return func(gc *Command) {
gc.Stdout = stdout
}
}
func WithStdin(stdin io.Reader) CommandModifier {
return func(gc *Command) {
gc.Stdin = stdin
}
}
func WithRepoDir(repoDir string) CommandModifier {
return func(gc *Command) {
gc.setRepoDir(repoDir)
}
}

39
git/errors.go Normal file
View file

@ -0,0 +1,39 @@
package git
import (
"errors"
"fmt"
)
// ErrNotOnAnyBranch indicates that the user is in detached HEAD state.
var ErrNotOnAnyBranch = errors.New("git: not on any branch")
type NotInstalled struct {
message string
err error
}
func (e *NotInstalled) Error() string {
return e.message
}
func (e *NotInstalled) Unwrap() error {
return e.err
}
type GitError struct {
ExitCode int
Stderr string
err error
}
func (ge *GitError) Error() string {
if ge.Stderr == "" {
return fmt.Sprintf("failed to run git: %v", ge.err)
}
return fmt.Sprintf("failed to run git: %s", ge.Stderr)
}
func (ge *GitError) Unwrap() error {
return ge.err
}

View file

@ -1,432 +0,0 @@
package git
import (
"bytes"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path"
"regexp"
"runtime"
"strings"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/safeexec"
)
// ErrNotOnAnyBranch indicates that the user is in detached HEAD state
var ErrNotOnAnyBranch = errors.New("git: not on any branch")
// Ref represents a git commit reference
type Ref struct {
Hash string
Name string
}
// TrackingRef represents a ref for a remote tracking branch
type TrackingRef struct {
RemoteName string
BranchName string
}
func (r TrackingRef) String() string {
return "refs/remotes/" + r.RemoteName + "/" + r.BranchName
}
// ShowRefs resolves fully-qualified refs to commit hashes
func ShowRefs(ref ...string) ([]Ref, error) {
args := append([]string{"show-ref", "--verify", "--"}, ref...)
showRef, err := GitCommand(args...)
if err != nil {
return nil, err
}
output, err := run.PrepareCmd(showRef).Output()
var refs []Ref
for _, line := range outputLines(output) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
refs = append(refs, Ref{
Hash: parts[0],
Name: parts[1],
})
}
return refs, err
}
// CurrentBranch reads the checked-out branch for the git repository
func CurrentBranch() (string, error) {
refCmd, err := GitCommand("symbolic-ref", "--quiet", "HEAD")
if err != nil {
return "", err
}
stderr := bytes.Buffer{}
refCmd.Stderr = &stderr
output, err := run.PrepareCmd(refCmd).Output()
if err == nil {
// Found the branch name
return getBranchShortName(output), nil
}
if stderr.Len() == 0 {
// Detached head
return "", ErrNotOnAnyBranch
}
return "", fmt.Errorf("%sgit: %s", stderr.String(), err)
}
func listRemotesForPath(path string) ([]string, error) {
remoteCmd, err := GitCommand("-C", path, "remote", "-v")
if err != nil {
return nil, err
}
output, err := run.PrepareCmd(remoteCmd).Output()
return outputLines(output), err
}
func listRemotes() ([]string, error) {
remoteCmd, err := GitCommand("remote", "-v")
if err != nil {
return nil, err
}
output, err := run.PrepareCmd(remoteCmd).Output()
return outputLines(output), err
}
func Config(name string) (string, error) {
configCmd, err := GitCommand("config", name)
if err != nil {
return "", err
}
output, err := run.PrepareCmd(configCmd).Output()
if err != nil {
return "", fmt.Errorf("unknown config key: %s", name)
}
return firstLine(output), nil
}
type NotInstalled struct {
message string
error
}
func (e *NotInstalled) Error() string {
return e.message
}
func GitCommand(args ...string) (*exec.Cmd, error) {
gitExe, err := safeexec.LookPath("git")
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
programName := "git"
if runtime.GOOS == "windows" {
programName = "Git for Windows"
}
return nil, &NotInstalled{
message: fmt.Sprintf("unable to find git executable in PATH; please install %s before retrying", programName),
error: err,
}
}
return nil, err
}
return exec.Command(gitExe, args...), nil
}
func UncommittedChangeCount() (int, error) {
statusCmd, err := GitCommand("status", "--porcelain")
if err != nil {
return 0, err
}
output, err := run.PrepareCmd(statusCmd).Output()
if err != nil {
return 0, err
}
lines := strings.Split(string(output), "\n")
count := 0
for _, l := range lines {
if l != "" {
count++
}
}
return count, nil
}
type Commit struct {
Sha string
Title string
}
func Commits(baseRef, headRef string) ([]*Commit, error) {
logCmd, err := GitCommand(
"-c", "log.ShowSignature=false",
"log", "--pretty=format:%H,%s",
"--cherry", fmt.Sprintf("%s...%s", baseRef, headRef))
if err != nil {
return nil, err
}
output, err := run.PrepareCmd(logCmd).Output()
if err != nil {
return []*Commit{}, err
}
commits := []*Commit{}
sha := 0
title := 1
for _, line := range outputLines(output) {
split := strings.SplitN(line, ",", 2)
if len(split) != 2 {
continue
}
commits = append(commits, &Commit{
Sha: split[sha],
Title: split[title],
})
}
if len(commits) == 0 {
return commits, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef)
}
return commits, nil
}
func lookupCommit(sha, format string) ([]byte, error) {
logCmd, err := GitCommand("-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:"+format, sha)
if err != nil {
return nil, err
}
return run.PrepareCmd(logCmd).Output()
}
func LastCommit() (*Commit, error) {
output, err := lookupCommit("HEAD", "%H,%s")
if err != nil {
return nil, err
}
idx := bytes.IndexByte(output, ',')
return &Commit{
Sha: string(output[0:idx]),
Title: strings.TrimSpace(string(output[idx+1:])),
}, nil
}
func CommitBody(sha string) (string, error) {
output, err := lookupCommit(sha, "%b")
return string(output), err
}
// Push publishes a git ref to a remote and sets up upstream configuration
func Push(remote string, ref string, cmdOut, cmdErr io.Writer) error {
pushCmd, err := GitCommand("push", "--set-upstream", remote, ref)
if err != nil {
return err
}
pushCmd.Stdout = cmdOut
pushCmd.Stderr = cmdErr
return run.PrepareCmd(pushCmd).Run()
}
type BranchConfig struct {
RemoteName string
RemoteURL *url.URL
MergeRef string
}
// ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config
func ReadBranchConfig(branch string) (cfg BranchConfig) {
prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch))
configCmd, err := GitCommand("config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix))
if err != nil {
return
}
output, err := run.PrepareCmd(configCmd).Output()
if err != nil {
return
}
for _, line := range outputLines(output) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
keys := strings.Split(parts[0], ".")
switch keys[len(keys)-1] {
case "remote":
if strings.Contains(parts[1], ":") {
u, err := ParseURL(parts[1])
if err != nil {
continue
}
cfg.RemoteURL = u
} else if !isFilesystemPath(parts[1]) {
cfg.RemoteName = parts[1]
}
case "merge":
cfg.MergeRef = parts[1]
}
}
return
}
func DeleteLocalBranch(branch string) error {
branchCmd, err := GitCommand("branch", "-D", branch)
if err != nil {
return err
}
return run.PrepareCmd(branchCmd).Run()
}
func HasLocalBranch(branch string) bool {
configCmd, err := GitCommand("rev-parse", "--verify", "refs/heads/"+branch)
if err != nil {
return false
}
_, err = run.PrepareCmd(configCmd).Output()
return err == nil
}
func CheckoutBranch(branch string) error {
configCmd, err := GitCommand("checkout", branch)
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)
if err != nil {
return err
}
pullCmd.Stdout = os.Stdout
pullCmd.Stderr = os.Stderr
pullCmd.Stdin = os.Stdin
return run.PrepareCmd(pullCmd).Run()
}
func parseCloneArgs(extraArgs []string) (args []string, target string) {
args = extraArgs
if len(args) > 0 {
if !strings.HasPrefix(args[0], "-") {
target, args = args[0], args[1:]
}
}
return
}
func RunClone(cloneURL string, args []string) (target string, err error) {
cloneArgs, target := parseCloneArgs(args)
cloneArgs = append(cloneArgs, cloneURL)
// If the args contain an explicit target, pass it to clone
// otherwise, parse the URL to determine where git cloned it to so we can return it
if target != "" {
cloneArgs = append(cloneArgs, target)
} else {
target = path.Base(strings.TrimSuffix(cloneURL, ".git"))
}
cloneArgs = append([]string{"clone"}, cloneArgs...)
cloneCmd, err := GitCommand(cloneArgs...)
if err != nil {
return "", err
}
cloneCmd.Stdin = os.Stdin
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
err = run.PrepareCmd(cloneCmd).Run()
return
}
func AddUpstreamRemote(upstreamURL, cloneDir string, branches []string) error {
args := []string{"-C", cloneDir, "remote", "add"}
for _, branch := range branches {
args = append(args, "-t", branch)
}
args = append(args, "-f", "upstream", upstreamURL)
cloneCmd, err := GitCommand(args...)
if err != nil {
return err
}
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
return run.PrepareCmd(cloneCmd).Run()
}
func isFilesystemPath(p string) bool {
return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/")
}
// ToplevelDir returns the top-level directory path of the current repository
func ToplevelDir() (string, error) {
showCmd, err := GitCommand("rev-parse", "--show-toplevel")
if err != nil {
return "", err
}
output, err := run.PrepareCmd(showCmd).Output()
return firstLine(output), err
}
// ToplevelDirFromPath returns the top-level given path of the current repository
func GetDirFromPath(p string) (string, error) {
showCmd, err := GitCommand("-C", p, "rev-parse", "--git-dir")
if err != nil {
return "", err
}
output, err := run.PrepareCmd(showCmd).Output()
return firstLine(output), err
}
func PathFromRepoRoot() string {
showCmd, err := GitCommand("rev-parse", "--show-prefix")
if err != nil {
return ""
}
output, err := run.PrepareCmd(showCmd).Output()
if err != nil {
return ""
}
if path := firstLine(output); path != "" {
return path[:len(path)-1]
}
return ""
}
func outputLines(output []byte) []string {
lines := strings.TrimSuffix(string(output), "\n")
return strings.Split(lines, "\n")
}
func firstLine(output []byte) string {
if i := bytes.IndexAny(output, "\n"); i >= 0 {
return string(output)[0:i]
}
return string(output)
}
func getBranchShortName(output []byte) string {
branch := firstLine(output)
return strings.TrimPrefix(branch, "refs/heads/")
}

View file

@ -1,220 +0,0 @@
package git
import (
"os"
"reflect"
"testing"
"github.com/cli/cli/v2/internal/run"
)
func setGitDir(t *testing.T, dir string) {
// TODO: also set XDG_CONFIG_HOME, GIT_CONFIG_NOSYSTEM
old_GIT_DIR := os.Getenv("GIT_DIR")
os.Setenv("GIT_DIR", dir)
t.Cleanup(func() {
os.Setenv("GIT_DIR", old_GIT_DIR)
})
}
func TestLastCommit(t *testing.T) {
setGitDir(t, "./fixtures/simple.git")
c, err := LastCommit()
if err != nil {
t.Fatalf("LastCommit error: %v", err)
}
if c.Sha != "6f1a2405cace1633d89a79c74c65f22fe78f9659" {
t.Errorf("expected sha %q, got %q", "6f1a2405cace1633d89a79c74c65f22fe78f9659", c.Sha)
}
if c.Title != "Second commit" {
t.Errorf("expected title %q, got %q", "Second commit", c.Title)
}
}
func TestCommitBody(t *testing.T) {
setGitDir(t, "./fixtures/simple.git")
body, err := CommitBody("6f1a2405cace1633d89a79c74c65f22fe78f9659")
if err != nil {
t.Fatalf("CommitBody error: %v", err)
}
if body != "I'm starting to get the hang of things\n" {
t.Errorf("expected %q, got %q", "I'm starting to get the hang of things\n", body)
}
}
/*
NOTE: below this are stubbed git tests, i.e. those that do not actually invoke `git`. If possible, utilize
`setGitDir()` to allow new tests to interact with `git`. For write operations, you can use `t.TempDir()` to
host a temporary git repository that is safe to be changed.
*/
func Test_UncommittedChangeCount(t *testing.T) {
type c struct {
Label string
Expected int
Output string
}
cases := []c{
{Label: "no changes", Expected: 0, Output: ""},
{Label: "one change", Expected: 1, Output: " M poem.txt"},
{Label: "untracked file", Expected: 2, Output: " M poem.txt\n?? new.txt"},
}
for _, v := range cases {
t.Run(v.Label, func(t *testing.T) {
cs, restore := run.Stub()
defer restore(t)
cs.Register(`git status --porcelain`, 0, v.Output)
ucc, _ := UncommittedChangeCount()
if ucc != v.Expected {
t.Errorf("UncommittedChangeCount() = %d, expected %d", ucc, v.Expected)
}
})
}
}
func Test_CurrentBranch(t *testing.T) {
type c struct {
Stub string
Expected string
}
cases := []c{
{
Stub: "branch-name\n",
Expected: "branch-name",
},
{
Stub: "refs/heads/branch-name\n",
Expected: "branch-name",
},
{
Stub: "refs/heads/branch\u00A0with\u00A0non\u00A0breaking\u00A0space\n",
Expected: "branch\u00A0with\u00A0non\u00A0breaking\u00A0space",
},
}
for _, v := range cases {
cs, teardown := run.Stub()
cs.Register(`git symbolic-ref --quiet HEAD`, 0, v.Stub)
result, err := CurrentBranch()
if err != nil {
t.Errorf("got unexpected error: %v", err)
}
if result != v.Expected {
t.Errorf("unexpected branch name: %s instead of %s", result, v.Expected)
}
teardown(t)
}
}
func Test_CurrentBranch_detached_head(t *testing.T) {
cs, teardown := run.Stub()
defer teardown(t)
cs.Register(`git symbolic-ref --quiet HEAD`, 1, "")
_, err := CurrentBranch()
if err == nil {
t.Fatal("expected an error, got nil")
}
if err != ErrNotOnAnyBranch {
t.Errorf("got unexpected error: %s instead of %s", err, ErrNotOnAnyBranch)
}
}
func TestParseExtraCloneArgs(t *testing.T) {
type Wanted struct {
args []string
dir string
}
tests := []struct {
name string
args []string
want Wanted
}{
{
name: "args and target",
args: []string{"target_directory", "-o", "upstream", "--depth", "1"},
want: Wanted{
args: []string{"-o", "upstream", "--depth", "1"},
dir: "target_directory",
},
},
{
name: "only args",
args: []string{"-o", "upstream", "--depth", "1"},
want: Wanted{
args: []string{"-o", "upstream", "--depth", "1"},
dir: "",
},
},
{
name: "only target",
args: []string{"target_directory"},
want: Wanted{
args: []string{},
dir: "target_directory",
},
},
{
name: "no args",
args: []string{},
want: Wanted{
args: []string{},
dir: "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args, dir := parseCloneArgs(tt.args)
got := Wanted{
args: args,
dir: dir,
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %#v want %#v", got, tt.want)
}
})
}
}
func TestAddUpstreamRemote(t *testing.T) {
tests := []struct {
name string
upstreamURL string
cloneDir string
branches []string
want string
}{
{
name: "fetch all",
upstreamURL: "URL",
cloneDir: "DIRECTORY",
branches: []string{},
want: "git -C DIRECTORY remote add -f upstream URL",
},
{
name: "fetch specific branches only",
upstreamURL: "URL",
cloneDir: "DIRECTORY",
branches: []string{"master", "dev"},
want: "git -C DIRECTORY remote add -t master -t dev -f upstream URL",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(tt.want, 0, "")
err := AddUpstreamRemote(tt.upstreamURL, tt.cloneDir, tt.branches)
if err != nil {
t.Fatalf("error running command `git remote add -f`: %v", err)
}
})
}
}

76
git/objects.go Normal file
View file

@ -0,0 +1,76 @@
package git
import (
"net/url"
"strings"
)
// RemoteSet is a slice of git remotes.
type RemoteSet []*Remote
func (r RemoteSet) Len() int { return len(r) }
func (r RemoteSet) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
func (r RemoteSet) Less(i, j int) bool {
return remoteNameSortScore(r[i].Name) > remoteNameSortScore(r[j].Name)
}
func remoteNameSortScore(name string) int {
switch strings.ToLower(name) {
case "upstream":
return 3
case "github":
return 2
case "origin":
return 1
default:
return 0
}
}
// Remote is a parsed git remote.
type Remote struct {
Name string
Resolved string
FetchURL *url.URL
PushURL *url.URL
}
func (r *Remote) String() string {
return r.Name
}
func NewRemote(name string, u string) *Remote {
pu, _ := url.Parse(u)
return &Remote{
Name: name,
FetchURL: pu,
PushURL: pu,
}
}
// Ref represents a git commit reference.
type Ref struct {
Hash string
Name string
}
// TrackingRef represents a ref for a remote tracking branch.
type TrackingRef struct {
RemoteName string
BranchName string
}
func (r TrackingRef) String() string {
return "refs/remotes/" + r.RemoteName + "/" + r.BranchName
}
type Commit struct {
Sha string
Title string
}
type BranchConfig struct {
RemoteName string
RemoteURL *url.URL
MergeRef string
}

View file

@ -1,169 +0,0 @@
package git
import (
"fmt"
"net/url"
"regexp"
"strings"
"github.com/cli/cli/v2/internal/run"
)
var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
// RemoteSet is a slice of git remotes
type RemoteSet []*Remote
func NewRemote(name string, u string) *Remote {
pu, _ := url.Parse(u)
return &Remote{
Name: name,
FetchURL: pu,
PushURL: pu,
}
}
// Remote is a parsed git remote
type Remote struct {
Name string
Resolved string
FetchURL *url.URL
PushURL *url.URL
}
func (r *Remote) String() string {
return r.Name
}
func remotes(path string, remoteList []string) (RemoteSet, error) {
remotes := parseRemotes(remoteList)
// this is affected by SetRemoteResolution
remoteCmd, err := GitCommand("-C", path, "config", "--get-regexp", `^remote\..*\.gh-resolved$`)
if err != nil {
return nil, err
}
output, _ := run.PrepareCmd(remoteCmd).Output()
for _, l := range outputLines(output) {
parts := strings.SplitN(l, " ", 2)
if len(parts) < 2 {
continue
}
rp := strings.SplitN(parts[0], ".", 3)
if len(rp) < 2 {
continue
}
name := rp[1]
for _, r := range remotes {
if r.Name == name {
r.Resolved = parts[1]
break
}
}
}
return remotes, nil
}
func RemotesForPath(path string) (RemoteSet, error) {
list, err := listRemotesForPath(path)
if err != nil {
return nil, err
}
return remotes(path, list)
}
// Remotes gets the git remotes set for the current repo
func Remotes() (RemoteSet, error) {
list, err := listRemotes()
if err != nil {
return nil, err
}
return remotes(".", list)
}
func parseRemotes(gitRemotes []string) (remotes RemoteSet) {
for _, r := range gitRemotes {
match := remoteRE.FindStringSubmatch(r)
if match == nil {
continue
}
name := strings.TrimSpace(match[1])
urlStr := strings.TrimSpace(match[2])
urlType := strings.TrimSpace(match[3])
var rem *Remote
if len(remotes) > 0 {
rem = remotes[len(remotes)-1]
if name != rem.Name {
rem = nil
}
}
if rem == nil {
rem = &Remote{Name: name}
remotes = append(remotes, rem)
}
u, err := ParseURL(urlStr)
if err != nil {
continue
}
switch urlType {
case "fetch":
rem.FetchURL = u
case "push":
rem.PushURL = u
}
}
return
}
// AddRemote adds a new git remote and auto-fetches objects from it
func AddRemote(name, u string) (*Remote, error) {
addCmd, err := GitCommand("remote", "add", "-f", name, u)
if err != nil {
return nil, err
}
err = run.PrepareCmd(addCmd).Run()
if err != nil {
return nil, err
}
var urlParsed *url.URL
if strings.HasPrefix(u, "https") {
urlParsed, err = url.Parse(u)
if err != nil {
return nil, err
}
} else {
urlParsed, err = ParseURL(u)
if err != nil {
return nil, err
}
}
return &Remote{
Name: name,
FetchURL: urlParsed,
PushURL: urlParsed,
}, nil
}
func UpdateRemoteURL(name, u string) error {
addCmd, err := GitCommand("remote", "set-url", name, u)
if err != nil {
return err
}
return run.PrepareCmd(addCmd).Run()
}
func SetRemoteResolution(name, resolution string) error {
addCmd, err := GitCommand("config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution)
if err != nil {
return err
}
return run.PrepareCmd(addCmd).Run()
}

View file

@ -1,35 +0,0 @@
package git
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_parseRemotes(t *testing.T) {
remoteList := []string{
"mona\tgit@github.com:monalisa/myfork.git (fetch)",
"origin\thttps://github.com/monalisa/octo-cat.git (fetch)",
"origin\thttps://github.com/monalisa/octo-cat-push.git (push)",
"upstream\thttps://example.com/nowhere.git (fetch)",
"upstream\thttps://github.com/hubot/tools (push)",
"zardoz\thttps://example.com/zed.git (push)",
}
r := parseRemotes(remoteList)
assert.Equal(t, 4, len(r))
assert.Equal(t, "mona", r[0].Name)
assert.Equal(t, "ssh://git@github.com/monalisa/myfork.git", r[0].FetchURL.String())
if r[0].PushURL != nil {
t.Errorf("expected no PushURL, got %q", r[0].PushURL)
}
assert.Equal(t, "origin", r[1].Name)
assert.Equal(t, "/monalisa/octo-cat.git", r[1].FetchURL.Path)
assert.Equal(t, "/monalisa/octo-cat-push.git", r[1].PushURL.Path)
assert.Equal(t, "upstream", r[2].Name)
assert.Equal(t, "example.com", r[2].FetchURL.Host)
assert.Equal(t, "github.com", r[2].PushURL.Host)
assert.Equal(t, "zardoz", r[3].Name)
}

View file

@ -1,171 +0,0 @@
package git
import (
"bufio"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/cli/cli/v2/internal/config"
)
var (
sshConfigLineRE = regexp.MustCompile(`\A\s*(?P<keyword>[A-Za-z][A-Za-z0-9]*)(?:\s+|\s*=\s*)(?P<argument>.+)`)
sshTokenRE = regexp.MustCompile(`%[%h]`)
)
// SSHAliasMap encapsulates the translation of SSH hostname aliases
type SSHAliasMap map[string]string
// Translator returns a function that applies hostname aliases to URLs
func (m SSHAliasMap) Translator() func(*url.URL) *url.URL {
return func(u *url.URL) *url.URL {
if u.Scheme != "ssh" {
return u
}
resolvedHost, ok := m[u.Hostname()]
if !ok {
return u
}
if strings.EqualFold(resolvedHost, "ssh.github.com") {
resolvedHost = "github.com"
}
newURL, _ := url.Parse(u.String())
newURL.Host = resolvedHost
return newURL
}
}
type sshParser struct {
homeDir string
aliasMap SSHAliasMap
hosts []string
open func(string) (io.Reader, error)
glob func(string) ([]string, error)
}
func (p *sshParser) read(fileName string) error {
var file io.Reader
if p.open == nil {
f, err := os.Open(fileName)
if err != nil {
return err
}
defer f.Close()
file = f
} else {
var err error
file, err = p.open(fileName)
if err != nil {
return err
}
}
if len(p.hosts) == 0 {
p.hosts = []string{"*"}
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
m := sshConfigLineRE.FindStringSubmatch(scanner.Text())
if len(m) < 3 {
continue
}
keyword, arguments := strings.ToLower(m[1]), m[2]
switch keyword {
case "host":
p.hosts = strings.Fields(arguments)
case "hostname":
for _, host := range p.hosts {
for _, name := range strings.Fields(arguments) {
if p.aliasMap == nil {
p.aliasMap = make(SSHAliasMap)
}
p.aliasMap[host] = sshExpandTokens(name, host)
}
}
case "include":
for _, arg := range strings.Fields(arguments) {
path := p.absolutePath(fileName, arg)
var fileNames []string
if p.glob == nil {
paths, _ := filepath.Glob(path)
for _, p := range paths {
if s, err := os.Stat(p); err == nil && !s.IsDir() {
fileNames = append(fileNames, p)
}
}
} else {
var err error
fileNames, err = p.glob(path)
if err != nil {
continue
}
}
for _, fileName := range fileNames {
_ = p.read(fileName)
}
}
}
}
return scanner.Err()
}
func (p *sshParser) absolutePath(parentFile, path string) string {
if filepath.IsAbs(path) || strings.HasPrefix(filepath.ToSlash(path), "/") {
return path
}
if strings.HasPrefix(path, "~") {
return filepath.Join(p.homeDir, strings.TrimPrefix(path, "~"))
}
if strings.HasPrefix(filepath.ToSlash(parentFile), "/etc/ssh") {
return filepath.Join("/etc/ssh", path)
}
return filepath.Join(p.homeDir, ".ssh", path)
}
// ParseSSHConfig constructs a map of SSH hostname aliases based on user and
// system configuration files
func ParseSSHConfig() SSHAliasMap {
configFiles := []string{
"/etc/ssh_config",
"/etc/ssh/ssh_config",
}
p := sshParser{}
if sshDir, err := config.HomeDirPath(".ssh"); err == nil {
userConfig := filepath.Join(sshDir, "config")
configFiles = append([]string{userConfig}, configFiles...)
p.homeDir = filepath.Dir(sshDir)
}
for _, file := range configFiles {
_ = p.read(file)
}
return p.aliasMap
}
func sshExpandTokens(text, host string) string {
return sshTokenRE.ReplaceAllStringFunc(text, func(match string) string {
switch match {
case "%h":
return host
case "%%":
return "%"
}
return ""
})
}

View file

@ -1,148 +0,0 @@
package git
import (
"bytes"
"fmt"
"io"
"net/url"
"path/filepath"
"testing"
"github.com/MakeNowJust/heredoc"
)
func Test_sshParser_read(t *testing.T) {
testFiles := map[string]string{
"/etc/ssh/config": heredoc.Doc(`
Include sites/*
`),
"/etc/ssh/sites/cfg1": heredoc.Doc(`
Host s1
Hostname=site1.net
`),
"/etc/ssh/sites/cfg2": heredoc.Doc(`
Host s2
Hostname = site2.net
`),
"HOME/.ssh/config": heredoc.Doc(`
Host *
Host gh gittyhubby
Hostname github.com
#Hostname example.com
Host ex
Include ex_config/*
`),
"HOME/.ssh/ex_config/ex_cfg": heredoc.Doc(`
Hostname example.com
`),
}
globResults := map[string][]string{
"/etc/ssh/sites/*": {"/etc/ssh/sites/cfg1", "/etc/ssh/sites/cfg2"},
"HOME/.ssh/ex_config/*": {"HOME/.ssh/ex_config/ex_cfg"},
}
p := &sshParser{
homeDir: "HOME",
open: func(s string) (io.Reader, error) {
if contents, ok := testFiles[filepath.ToSlash(s)]; ok {
return bytes.NewBufferString(contents), nil
} else {
return nil, fmt.Errorf("no test file stub found: %q", s)
}
},
glob: func(p string) ([]string, error) {
if results, ok := globResults[filepath.ToSlash(p)]; ok {
return results, nil
} else {
return nil, fmt.Errorf("no glob stubs found: %q", p)
}
},
}
if err := p.read("/etc/ssh/config"); err != nil {
t.Fatalf("read(global config) = %v", err)
}
if err := p.read("HOME/.ssh/config"); err != nil {
t.Fatalf("read(user config) = %v", err)
}
if got := p.aliasMap["gh"]; got != "github.com" {
t.Errorf("expected alias %q to expand to %q, got %q", "gh", "github.com", got)
}
if got := p.aliasMap["gittyhubby"]; got != "github.com" {
t.Errorf("expected alias %q to expand to %q, got %q", "gittyhubby", "github.com", got)
}
if got := p.aliasMap["example.com"]; got != "" {
t.Errorf("expected alias %q to expand to %q, got %q", "example.com", "", got)
}
if got := p.aliasMap["ex"]; got != "example.com" {
t.Errorf("expected alias %q to expand to %q, got %q", "ex", "example.com", got)
}
if got := p.aliasMap["s1"]; got != "site1.net" {
t.Errorf("expected alias %q to expand to %q, got %q", "s1", "site1.net", got)
}
}
func Test_sshParser_absolutePath(t *testing.T) {
dir := "HOME"
p := &sshParser{homeDir: dir}
tests := map[string]struct {
parentFile string
arg string
want string
wantErr bool
}{
"absolute path": {
parentFile: "/etc/ssh/ssh_config",
arg: "/etc/ssh/config",
want: "/etc/ssh/config",
},
"system relative path": {
parentFile: "/etc/ssh/config",
arg: "configs/*.conf",
want: filepath.Join("/etc", "ssh", "configs", "*.conf"),
},
"user relative path": {
parentFile: filepath.Join(dir, ".ssh", "ssh_config"),
arg: "configs/*.conf",
want: filepath.Join(dir, ".ssh", "configs/*.conf"),
},
"shell-like ~ rerefence": {
parentFile: filepath.Join(dir, ".ssh", "ssh_config"),
arg: "~/.ssh/*.conf",
want: filepath.Join(dir, ".ssh", "*.conf"),
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
if got := p.absolutePath(tt.parentFile, tt.arg); got != tt.want {
t.Errorf("absolutePath(): %q, wants %q", got, tt.want)
}
})
}
}
func Test_Translator(t *testing.T) {
m := SSHAliasMap{
"gh": "github.com",
"github.com": "ssh.github.com",
"my.gh.com": "ssh.github.com",
}
tr := m.Translator()
cases := [][]string{
{"ssh://gh/o/r", "ssh://github.com/o/r"},
{"ssh://github.com/o/r", "ssh://github.com/o/r"},
{"ssh://my.gh.com", "ssh://github.com"},
{"https://gh/o/r", "https://gh/o/r"},
}
for _, c := range cases {
u, _ := url.Parse(c[0])
got := tr(u)
if got.String() != c[1] {
t.Errorf("%q: expected %q, got %q", c[0], c[1], got)
}
}
}

81
go.mod
View file

@ -1,46 +1,83 @@
module github.com/cli/cli/v2
go 1.16
go 1.18
require (
github.com/AlecAivazis/survey/v2 v2.3.2
github.com/AlecAivazis/survey/v2 v2.3.6
github.com/MakeNowJust/heredoc v1.0.0
github.com/briandowns/spinner v1.18.0
github.com/charmbracelet/glamour v0.4.0
github.com/cli/browser v1.1.0
github.com/briandowns/spinner v1.18.1
github.com/cenkalti/backoff/v4 v4.1.3
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da
github.com/charmbracelet/lipgloss v0.5.0
github.com/cli/go-gh v1.0.0
github.com/cli/oauth v0.9.0
github.com/cli/safeexec v1.0.0
github.com/cli/shurcooL-graphql v0.0.1
github.com/cpuguy83/go-md2man/v2 v2.0.1
github.com/creack/pty v1.1.17
github.com/gabriel-vasile/mimetype v1.4.0
github.com/google/go-cmp v0.5.6
github.com/cpuguy83/go-md2man/v2 v2.0.2
github.com/creack/pty v1.1.18
github.com/gabriel-vasile/mimetype v1.4.1
github.com/gdamore/tcell/v2 v2.5.3
github.com/google/go-cmp v0.5.9
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/websocket v1.4.2
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-version v1.3.0
github.com/henvic/httpretty v0.0.6
github.com/itchyny/gojq v0.12.6
github.com/joho/godotenv v1.4.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-colorable v0.1.12
github.com/mattn/go-isatty v0.0.14
github.com/mattn/go-colorable v0.1.13
github.com/mattn/go-isatty v0.0.16
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.9.0
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38
github.com/opentracing/opentracing-go v1.1.0
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b
github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a // indirect
github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d
github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00
github.com/sourcegraph/jsonrpc2 v0.1.0
github.com/spf13/cobra v1.3.0
github.com/spf13/cobra v1.5.0
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.7.0
github.com/stretchr/testify v1.7.5
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d
golang.org/x/term v0.0.0-20210503060354-a79de5458b56
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
golang.org/x/text v0.3.8
google.golang.org/grpc v1.49.0
google.golang.org/protobuf v1.27.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/alecthomas/chroma v0.10.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cli/browser v1.1.0 // indirect
github.com/cli/shurcooL-graphql v0.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/fatih/color v1.7.0 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/itchyny/gojq v0.12.8 // indirect
github.com/itchyny/timefmt-go v0.1.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/microcosm-cc/bluemonday v1.0.20 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.12.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 // indirect
github.com/stretchr/objx v0.4.0 // indirect
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect
github.com/yuin/goldmark v1.4.4 // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect
golang.org/x/net v0.0.0-20220923203811-8be639271d50 // indirect
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)
replace golang.org/x/crypto => github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03

505
go.sum
View file

@ -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=
@ -46,76 +31,50 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/AlecAivazis/survey/v2 v2.3.2 h1:TqTB+aDDCLYhf9/bD2TwSO8u8jDSmMUd2SUVO4gCnU8=
github.com/AlecAivazis/survey/v2 v2.3.2/go.mod h1:TH2kPCDU3Kqq7pLbnCWwZXDBjnhZtmsCle5EiYDJ2fg=
github.com/AlecAivazis/survey/v2 v2.3.6 h1:NvTuVHISgTHEHeBFqt6BHOe4Ny/NwGZr7w+F8S9ziyw=
github.com/AlecAivazis/survey/v2 v2.3.6/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI=
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/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
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.0 h1:SJs0maNOs4FqhBwiJ3Gr7Z1D39/rukIVGQvpNZVHVcM=
github.com/briandowns/spinner v1.18.0/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ=
github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY=
github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4=
github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
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/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da h1:FGz53GWQRiKQ/5xUsoCCkewSQIC7u81Scaxx2nUy3nM=
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da/go.mod h1:HXz79SMFnF9arKxqeoHWxmo1BhplAH7wehlRhKQIL94=
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
github.com/cli/browser v1.1.0 h1:xOZBfkfY9L9vMBgqb1YwRirGu6QFaQ5dP/vXt5ENSOY=
github.com/cli/browser v1.1.0/go.mod h1:HKMQAt9t12kov91Mn7RfZxyJQQgWgyS/3SZswlZ5iTI=
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03 h1:3f4uHLfWx4/WlnMPXGai03eoWAI+oGHJwr+5OXfxCr8=
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
github.com/cli/go-gh v1.0.0 h1:zE1YUAUYqGXNZuICEBeOkIMJ5F50BS0ftvtoWGlsEFI=
github.com/cli/go-gh v1.0.0/go.mod h1:bqxLdCoTZ73BuiPEJx4olcO/XKhVZaFDchFagYRBweE=
github.com/cli/oauth v0.9.0 h1:nxBC0Df4tUzMkqffAB+uZvisOwT3/N9FpkfdTDtafxc=
github.com/cli/oauth v0.9.0/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
github.com/cli/shurcooL-graphql v0.0.1 h1:/9J3t9O6p1B8zdBBtQighq5g7DQRItBwuwGh3SocsKM=
github.com/cli/shurcooL-graphql v0.0.1/go.mod h1:U7gCSuMZP/Qy7kbqkk5PrqXEeDgtfG5K+W+u8weorps=
github.com/cli/shurcooL-graphql v0.0.2 h1:rwP5/qQQ2fM0TzkUTwtt6E2LbIYf6R+39cUXTa04NYk=
github.com/cli/shurcooL-graphql v0.0.2/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA=
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=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -124,38 +83,22 @@ 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/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.5.3 h1:b9XQrT6QGbgI7JvZOJXFNczOQeIYbo8BfeSMzt2sAV0=
github.com/gdamore/tcell/v2 v2.5.3/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo=
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 +106,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=
@ -178,12 +119,9 @@ github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:W
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/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,17 +131,12 @@ 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 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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=
@ -211,239 +144,127 @@ 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/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
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/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
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.6 h1:VjaFn59Em2wTxDNGcrRkDK9ZHMNa8IksOgL13sLL4d0=
github.com/itchyny/gojq v0.12.6/go.mod h1:ZHrkfu7A+RbZLy5J1/JKpS4poEqrzItSTGDItqsfP0A=
github.com/itchyny/gojq v0.12.8 h1:Zxcwq8w4IeR8JJYEtoG2MWJZUv0RGY6QqJcO1cqV8+A=
github.com/itchyny/gojq v0.12.8/go.mod h1:gE2kZ9fVRU0+JAksaTzjIlgnCa2akU+a1V0WXgJQN5c=
github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU=
github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/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=
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-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84=
github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microcosm-cc/bluemonday v1.0.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/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
github.com/microcosm-cc/bluemonday v1.0.20 h1:flpzsq4KU3QIYAYGV/szUat7H+GPOXR0B2JU5A1Wp8Y=
github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
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/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc=
github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A=
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/tview v0.0.0-20221029100920-c4a7e501810d h1:jKIUJdMcIVGOSHi6LSqJqw9RqblyblE2ZrHvFbWR3S0=
github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d/go.mod h1:YX2wUZOcJGOIycErz2s9KvDaP0jnWwRCirQMPLPpQ+Y=
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/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8=
github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00 h1:fiFvD4lT0aWjuuAb64LlZ/67v87m+Kc9Qsu5cMFNK0w=
github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 h1:B1PEwpArrNp4dkQrfxh/abbBAOZBVp0ds+fBEOUOqOc=
github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
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.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
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/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/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
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/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q=
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8=
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
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=
@ -466,8 +287,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=
@ -476,25 +295,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=
@ -509,39 +320,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/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220923203811-8be639271d50 h1:vKyz8L3zkd+xrMeIaBsQ/MNVPVFSffdaU3ZyYlBGFnI=
golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
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=
@ -550,35 +341,23 @@ 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=
@ -590,49 +369,34 @@ 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 h1:FjkYO/PPp4Wi0EAUOVLxePm7qVW4r4ctbWpURyuOD0E=
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -649,7 +413,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=
@ -674,26 +437,12 @@ 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=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
@ -711,30 +460,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=
@ -758,46 +490,13 @@ 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 h1:PDIOdWxZ8eRizhKa1AAvY53xsvLB1cWorMjslvY3VA8=
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=
@ -810,22 +509,8 @@ 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/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw=
google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
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=
@ -840,23 +525,17 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
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/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/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
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=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View file

@ -5,14 +5,18 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/utils"
"github.com/cli/oauth"
"github.com/henvic/httpretty"
)
var (
@ -20,10 +24,13 @@ var (
oauthClientID = "178c6fc778ccc68e1d6a"
// This value is safe to be embedded in version control
oauthClientSecret = "34ddeff2b558a23d38fba8a6de74f086ede1cc0b"
jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
)
type iconfig interface {
Set(string, string, string) error
Get(string, string) (string, error)
Set(string, string, string)
Write() error
}
@ -31,31 +38,35 @@ func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice s
// TODO this probably shouldn't live in this package. It should probably be in a new package that
// depends on both iostreams and config.
token, userLogin, err := authFlow(hostname, IO, notice, additionalScopes, isInteractive)
// FIXME: this duplicates `factory.browserLauncher()`
browserLauncher := os.Getenv("GH_BROWSER")
if browserLauncher == "" {
browserLauncher, _ = cfg.Get("", "browser")
}
if browserLauncher == "" {
browserLauncher = os.Getenv("BROWSER")
}
token, userLogin, err := authFlow(hostname, IO, notice, additionalScopes, isInteractive, browserLauncher)
if err != nil {
return "", err
}
err = cfg.Set(hostname, "user", userLogin)
if err != nil {
return "", err
}
err = cfg.Set(hostname, "oauth_token", token)
if err != nil {
return "", err
}
cfg.Set(hostname, "user", userLogin)
cfg.Set(hostname, "oauth_token", token)
return token, cfg.Write()
}
func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string, isInteractive bool) (string, string, error) {
func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string, isInteractive bool, browserLauncher string) (string, string, error) {
w := IO.ErrOut
cs := IO.ColorScheme()
httpClient := http.DefaultClient
if envDebug := os.Getenv("DEBUG"); envDebug != "" {
logTraffic := strings.Contains(envDebug, "api") || strings.Contains(envDebug, "oauth")
httpClient.Transport = api.VerboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport)
httpClient := &http.Client{}
debugEnabled, debugValue := utils.IsDebugEnabled()
if debugEnabled {
logTraffic := strings.Contains(debugValue, "api")
httpClient.Transport = verboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport)
}
minimumScopes := []string{"repo", "read:org", "gist"}
@ -78,19 +89,26 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
fmt.Fprintf(w, "%s First copy your one-time code: %s\n", cs.Yellow("!"), cs.Bold(code))
return nil
},
BrowseURL: func(url string) error {
BrowseURL: func(authURL string) error {
if u, err := url.Parse(authURL); err == nil {
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("invalid URL: %s", authURL)
}
} else {
return err
}
if !isInteractive {
fmt.Fprintf(w, "%s to continue in your web browser: %s\n", cs.Bold("Open this URL"), url)
fmt.Fprintf(w, "%s to continue in your web browser: %s\n", cs.Bold("Open this URL"), authURL)
return nil
}
fmt.Fprintf(w, "%s to open %s in your browser... ", cs.Bold("Press Enter"), oauthHost)
_ = waitForEnter(IO.In)
// FIXME: read the browser from cmd Factory rather than recreating it
browser := cmdutil.NewBrowser(os.Getenv("BROWSER"), IO.Out, IO.ErrOut)
if err := browser.Browse(url); err != nil {
fmt.Fprintf(w, "%s Failed opening a web browser at %s\n", cs.Red("!"), url)
b := browser.New(browserLauncher, IO.Out, IO.ErrOut)
if err := b.Browse(authURL); err != nil {
fmt.Fprintf(w, "%s Failed opening a web browser at %s\n", cs.Red("!"), authURL)
fmt.Fprintf(w, " %s\n", err)
fmt.Fprint(w, " Please try entering the URL in your browser manually\n")
}
@ -111,7 +129,7 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
return "", "", err
}
userLogin, err := getViewer(oauthHost, token.Token)
userLogin, err := getViewer(oauthHost, token.Token, IO.ErrOut)
if err != nil {
return "", "", err
}
@ -119,9 +137,24 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
return token.Token, userLogin, nil
}
func getViewer(hostname, token string) (string, error) {
http := api.NewClient(api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
return api.CurrentLoginName(http, hostname)
type cfg struct {
authToken string
}
func (c cfg) AuthToken(hostname string) (string, string) {
return c.authToken, "oauth_token"
}
func getViewer(hostname, token string, logWriter io.Writer) (string, error) {
opts := api.HTTPClientOptions{
Config: cfg{authToken: token},
Log: logWriter,
}
client, err := api.NewHTTPClient(opts)
if err != nil {
return "", err
}
return api.CurrentLoginName(api.NewClientFromHTTP(client), hostname)
}
func waitForEnter(r io.Reader) error {
@ -129,3 +162,28 @@ func waitForEnter(r io.Reader) error {
scanner.Scan()
return scanner.Err()
}
func verboseLog(out io.Writer, logTraffic bool, colorize bool) func(http.RoundTripper) http.RoundTripper {
logger := &httpretty.Logger{
Time: true,
TLS: false,
Colors: colorize,
RequestHeader: logTraffic,
RequestBody: logTraffic,
ResponseHeader: logTraffic,
ResponseBody: logTraffic,
Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}},
MaxResponseBody: 10000,
}
logger.SetOutput(out)
logger.SetBodyFilter(func(h http.Header) (skip bool, err error) {
return !inspectableMIMEType(h.Get("Content-Type")), nil
})
return logger.RoundTripper
}
func inspectableMIMEType(t string) bool {
return strings.HasPrefix(t, "text/") ||
strings.HasPrefix(t, "application/x-www-form-urlencoded") ||
jsonTypeRE.MatchString(t)
}

View file

@ -0,0 +1,16 @@
package browser
import (
"io"
ghBrowser "github.com/cli/go-gh/pkg/browser"
)
type Browser interface {
Browse(string) error
}
func New(launcher string, stdout, stderr io.Writer) Browser {
b := ghBrowser.New(launcher, stdout, stderr)
return &b
}

40
internal/browser/stub.go Normal file
View file

@ -0,0 +1,40 @@
package browser
type Stub struct {
urls []string
}
func (b *Stub) Browse(url string) error {
b.urls = append(b.urls, url)
return nil
}
func (b *Stub) BrowsedURL() string {
if len(b.urls) > 0 {
return b.urls[0]
}
return ""
}
type _testing interface {
Errorf(string, ...interface{})
Helper()
}
func (b *Stub) Verify(t _testing, url string) {
t.Helper()
if url != "" {
switch len(b.urls) {
case 0:
t.Errorf("expected browser to open URL %q, but it was never invoked", url)
case 1:
if url != b.urls[0] {
t.Errorf("expected browser to open URL %q, got %q", url, b.urls[0])
}
default:
t.Errorf("expected browser to open one URL, but was invoked %d times", len(b.urls))
}
} else if len(b.urls) > 0 {
t.Errorf("expected no browser to open, but was invoked %d times: %v", len(b.urls), b.urls)
}
}

View file

@ -13,7 +13,6 @@ package api
// - github.GetUser(github.Client)
// - github.GetRepository(Client)
// - github.ReadFile(Client, nwo, branch, path) // was GetCodespaceRepositoryContents
// - github.AuthorizedKeys(Client, user)
// - codespaces.Create(Client, user, repo, sku, branch, location)
// - codespaces.Delete(Client, user, token, name)
// - codespaces.Get(Client, token, owner, name)
@ -32,7 +31,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"io"
"net/http"
"net/url"
"reflect"
@ -51,12 +50,20 @@ 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
vscsAPI string
githubAPI string
githubServer string
retryBackoff time.Duration
}
type httpClient interface {
@ -79,12 +86,14 @@ func New(serverURL, apiURL, vscsURL string, httpClient httpClient) *API {
vscsAPI: strings.TrimSuffix(vscsURL, "/"),
githubAPI: strings.TrimSuffix(apiURL, "/"),
githubServer: strings.TrimSuffix(serverURL, "/"),
retryBackoff: 100 * time.Millisecond,
}
}
// User represents a GitHub user.
type User struct {
Login string `json:"login"`
Type string `json:"type"`
}
// GetUser returns the user associated with the given token.
@ -105,7 +114,7 @@ func (a *API) GetUser(ctx context.Context) (*User, error) {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
@ -143,7 +152,7 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
@ -157,15 +166,24 @@ 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"`
LastUsedAt string `json:"last_used_at"`
Owner User `json:"owner"`
Repository Repository `json:"repository"`
State string `json:"state"`
GitStatus CodespaceGitStatus `json:"git_status"`
Connection CodespaceConnection `json:"connection"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
DisplayName string `json:"display_name"`
LastUsedAt string `json:"last_used_at"`
Owner User `json:"owner"`
Repository Repository `json:"repository"`
State string `json:"state"`
GitStatus CodespaceGitStatus `json:"git_status"`
Connection CodespaceConnection `json:"connection"`
Machine CodespaceMachine `json:"machine"`
VSCSTarget string `json:"vscs_target"`
PendingOperation bool `json:"pending_operation"`
PendingOperationDisabledReason string `json:"pending_operation_disabled_reason"`
IdleTimeoutNotice string `json:"idle_timeout_notice"`
WebURL string `json:"web_url"`
}
type CodespaceGitStatus struct {
@ -176,6 +194,15 @@ type CodespaceGitStatus struct {
HasUncommitedChanges bool `json:"has_uncommited_changes"`
}
type CodespaceMachine struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
OperatingSystem string `json:"operating_system"`
StorageInBytes uint64 `json:"storage_in_bytes"`
MemoryInBytes uint64 `json:"memory_in_bytes"`
CPUCount int `json:"cpus"`
}
const (
// CodespaceStateAvailable is the state for a running codespace environment.
CodespaceStateAvailable = "Available"
@ -183,6 +210,8 @@ const (
CodespaceStateShutdown = "Shutdown"
// CodespaceStateStarting is the state for a starting codespace environment.
CodespaceStateStarting = "Starting"
// CodespaceStateRebuilding is the state for a rebuilding codespace environment.
CodespaceStateRebuilding = "Rebuilding"
)
type CodespaceConnection struct {
@ -195,6 +224,7 @@ type CodespaceConnection struct {
// CodespaceFields is the list of exportable fields for a codespace.
var CodespaceFields = []string{
"displayName",
"name",
"owner",
"repository",
@ -202,6 +232,8 @@ var CodespaceFields = []string{
"gitStatus",
"createdAt",
"lastUsedAt",
"machineName",
"vscsTarget",
}
func (c *Codespace) ExportData(fields []string) map[string]interface{} {
@ -214,12 +246,18 @@ func (c *Codespace) ExportData(fields []string) map[string]interface{} {
data[f] = c.Owner.Login
case "repository":
data[f] = c.Repository.FullName
case "machineName":
data[f] = c.Machine.Name
case "gitStatus":
data[f] = map[string]interface{}{
"ref": c.GitStatus.Ref,
"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)
@ -231,15 +269,49 @@ func (c *Codespace) ExportData(fields []string) map[string]interface{} {
return data
}
type ListCodespacesOptions struct {
OrgName string
UserName string
RepoName string
Limit int
}
// ListCodespaces returns a list of codespaces for the user. Pass a negative limit to request all pages from
// the API until all codespaces have been fetched.
func (a *API) ListCodespaces(ctx context.Context, limit int) (codespaces []*Codespace, err error) {
perPage := 100
func (a *API) ListCodespaces(ctx context.Context, opts ListCodespacesOptions) (codespaces []*Codespace, err error) {
var (
perPage = 100
limit = opts.Limit
)
if limit > 0 && limit < 100 {
perPage = limit
}
listURL := fmt.Sprintf("%s/user/codespaces?per_page=%d", a.githubAPI, perPage)
var (
listURL string
spanName string
)
if opts.RepoName != "" {
listURL = fmt.Sprintf("%s/repos/%s/codespaces?per_page=%d", a.githubAPI, opts.RepoName, perPage)
spanName = "/repos/*/codespaces"
} else if opts.OrgName != "" {
// the endpoints below can only be called by the organization admins
orgName := opts.OrgName
if opts.UserName != "" {
userName := opts.UserName
listURL = fmt.Sprintf("%s/orgs/%s/members/%s/codespaces?per_page=%d", a.githubAPI, orgName, userName, perPage)
spanName = "/orgs/*/members/*/codespaces"
} else {
listURL = fmt.Sprintf("%s/orgs/%s/codespaces?per_page=%d", a.githubAPI, orgName, perPage)
spanName = "/orgs/*/codespaces"
}
} else {
listURL = fmt.Sprintf("%s/user/codespaces?per_page=%d", a.githubAPI, perPage)
spanName = "/user/codespaces"
}
for {
req, err := http.NewRequest(http.MethodGet, listURL, nil)
if err != nil {
@ -247,7 +319,7 @@ func (a *API) ListCodespaces(ctx context.Context, limit int) (codespaces []*Code
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces")
resp, err := a.do(ctx, req, spanName)
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
@ -260,6 +332,7 @@ func (a *API) ListCodespaces(ctx context.Context, limit int) (codespaces []*Code
var response struct {
Codespaces []*Codespace `json:"codespaces"`
}
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
@ -297,28 +370,74 @@ func findNextPage(linkValue string) string {
return ""
}
func (a *API) GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*Codespace, error) {
perPage := 100
listURL := fmt.Sprintf("%s/orgs/%s/members/%s/codespaces?per_page=%d", a.githubAPI, orgName, userName, perPage)
for {
req, err := http.NewRequest(http.MethodGet, listURL, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/orgs/*/members/*/codespaces")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
var response struct {
Codespaces []*Codespace `json:"codespaces"`
}
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
for _, cs := range response.Codespaces {
if cs.Name == codespaceName {
return cs, nil
}
}
nextURL := findNextPage(resp.Header.Get("Link"))
if nextURL == "" {
break
}
listURL = nextURL
}
return nil, fmt.Errorf("codespace not found for user %s with name %s", userName, codespaceName)
}
// GetCodespace returns the user codespace based on the provided name.
// If the codespace is not found, an error is returned.
// If includeConnection is true, it will return the connection information for the codespace.
func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeConnection bool) (*Codespace, error) {
req, err := http.NewRequest(
http.MethodGet,
a.githubAPI+"/user/codespaces/"+codespaceName,
nil,
)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
if includeConnection {
q := req.URL.Query()
q.Add("internal", "true")
q.Add("refresh", "true")
req.URL.RawQuery = q.Encode()
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*")
resp, err := a.withRetry(func() (*http.Response, error) {
req, err := http.NewRequest(
http.MethodGet,
a.githubAPI+"/user/codespaces/"+codespaceName,
nil,
)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
if includeConnection {
q := req.URL.Query()
q.Add("internal", "true")
q.Add("refresh", "true")
req.URL.RawQuery = q.Encode()
}
a.setHeaders(req)
return a.do(ctx, req, "/user/codespaces/*")
})
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
@ -328,7 +447,7 @@ func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeCon
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
@ -344,17 +463,18 @@ func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeCon
// StartCodespace starts a codespace for the user.
// If the codespace is already running, the returned error from the API is ignored.
func (a *API) StartCodespace(ctx context.Context, codespaceName string) error {
req, err := http.NewRequest(
http.MethodPost,
a.githubAPI+"/user/codespaces/"+codespaceName+"/start",
nil,
)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*/start")
resp, err := a.withRetry(func() (*http.Response, error) {
req, err := http.NewRequest(
http.MethodPost,
a.githubAPI+"/user/codespaces/"+codespaceName+"/start",
nil,
)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
return a.do(ctx, req, "/user/codespaces/*/start")
})
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
@ -371,18 +491,25 @@ func (a *API) StartCodespace(ctx context.Context, codespaceName string) error {
return nil
}
func (a *API) StopCodespace(ctx context.Context, codespaceName string) error {
req, err := http.NewRequest(
http.MethodPost,
a.githubAPI+"/user/codespaces/"+codespaceName+"/stop",
nil,
)
func (a *API) StopCodespace(ctx context.Context, codespaceName string, orgName string, userName string) error {
var stopURL string
var spanName string
if orgName != "" {
stopURL = fmt.Sprintf("%s/orgs/%s/members/%s/codespaces/%s/stop", a.githubAPI, orgName, userName, codespaceName)
spanName = "/orgs/*/members/*/codespaces/*/stop"
} else {
stopURL = fmt.Sprintf("%s/user/codespaces/%s/stop", a.githubAPI, codespaceName)
spanName = "/user/codespaces/*/stop"
}
req, err := http.NewRequest(http.MethodPost, stopURL, nil)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*/stop")
resp, err := a.do(ctx, req, spanName)
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
@ -395,40 +522,6 @@ func (a *API) StopCodespace(ctx context.Context, codespaceName string) error {
return nil
}
type getCodespaceRegionLocationResponse struct {
Current string `json:"current"`
}
// GetCodespaceRegionLocation returns the closest codespace location for the user.
func (a *API) GetCodespaceRegionLocation(ctx context.Context) (string, error) {
req, err := http.NewRequest(http.MethodGet, a.vscsAPI+"/api/v1/locations", nil)
if err != nil {
return "", fmt.Errorf("error creating request: %w", err)
}
resp, err := a.do(ctx, req, req.URL.String())
if err != nil {
return "", fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response body: %w", err)
}
var response getCodespaceRegionLocationResponse
if err := json.Unmarshal(b, &response); err != nil {
return "", fmt.Errorf("error unmarshaling response: %w", err)
}
return response.Current, nil
}
type Machine struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
@ -436,7 +529,7 @@ type Machine struct {
}
// GetCodespacesMachines returns the codespaces machines for the given repo, branch and location.
func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*Machine, error) {
func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*Machine, error) {
reqURL := fmt.Sprintf("%s/repositories/%d/codespaces/machines", a.githubAPI, repoID)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
@ -446,6 +539,7 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc
q := req.URL.Query()
q.Add("location", location)
q.Add("ref", branch)
q.Add("devcontainer_path", devcontainerPath)
req.URL.RawQuery = q.Encode()
a.setHeaders(req)
@ -459,7 +553,7 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
@ -474,20 +568,147 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc
return response.Machines, nil
}
// RepoSearchParameters are the optional parameters for searching for repositories.
type RepoSearchParameters struct {
// The maximum number of repos to return. At most 100 repos are returned even if this value is greater than 100.
MaxRepos int
// The sort order for returned repos. Possible values are 'stars', 'forks', 'help-wanted-issues', or 'updated'. If empty the API's default ordering is used.
Sort string
}
// GetCodespaceRepoSuggestions searches for and returns repo names based on the provided search text.
func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, parameters RepoSearchParameters) ([]string, error) {
reqURL := fmt.Sprintf("%s/search/repositories", a.githubAPI)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
parts := strings.SplitN(partialSearch, "/", 2)
var nameSearch string
if len(parts) == 2 {
user := parts[0]
repo := parts[1]
nameSearch = fmt.Sprintf("%s user:%s", repo, user)
} else {
/*
* This results in searching for the text within the owner or the name. It's possible to
* do an owner search and then look up some repos for those owners, but that adds a
* good amount of latency to the fetch which slows down showing the suggestions.
*/
nameSearch = partialSearch
}
queryStr := fmt.Sprintf("%s in:name", nameSearch)
q := req.URL.Query()
q.Add("q", queryStr)
if len(parameters.Sort) > 0 {
q.Add("sort", parameters.Sort)
}
if parameters.MaxRepos > 0 {
q.Add("per_page", strconv.Itoa(parameters.MaxRepos))
}
req.URL.RawQuery = q.Encode()
a.setHeaders(req)
resp, err := a.do(ctx, req, "/search/repositories/*")
if err != nil {
return nil, fmt.Errorf("error searching repositories: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var response struct {
Items []*Repository `json:"items"`
}
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
repoNames := make([]string, len(response.Items))
for i, repo := range response.Items {
repoNames[i] = repo.FullName
}
return repoNames, nil
}
// GetCodespaceBillableOwner returns the billable owner and expected default values for
// codespaces created by the user for a given repository.
func (a *API) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*User, error) {
req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+nwo+"/codespaces/new", nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/repos/*/codespaces/new")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
} else if resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("you cannot create codespaces with that repository")
} else if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var response struct {
BillableOwner User `json:"billable_owner"`
Defaults struct {
DevcontainerPath string `json:"devcontainer_path"`
Location string `json:"location"`
}
}
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
// While this response contains further helpful information ahead of codespace creation,
// we're only referencing the billable owner today.
return &response.BillableOwner, nil
}
// CreateCodespaceParams are the required parameters for provisioning a Codespace.
type CreateCodespaceParams struct {
RepositoryID int
IdleTimeoutMinutes int
Branch string
Machine string
Location string
RepositoryID int
IdleTimeoutMinutes int
RetentionPeriodMinutes *int
Branch string
Machine string
Location string
DevContainerPath string
VSCSTarget string
VSCSTargetURL string
PermissionsOptOut bool
}
// CreateCodespace creates a codespace with the given parameters and returns a non-nil error if it
// fails to create.
func (a *API) CreateCodespace(ctx context.Context, params *CreateCodespaceParams) (*Codespace, error) {
codespace, err := a.startCreate(ctx, params)
if err != errProvisioningInProgress {
if !errors.Is(err, errProvisioningInProgress) {
return codespace, err
}
@ -521,15 +742,29 @@ func (a *API) CreateCodespace(ctx context.Context, params *CreateCodespaceParams
}
type startCreateRequest struct {
RepositoryID int `json:"repository_id"`
IdleTimeoutMinutes int `json:"idle_timeout_minutes,omitempty"`
Ref string `json:"ref"`
Location string `json:"location"`
Machine string `json:"machine"`
RepositoryID int `json:"repository_id"`
IdleTimeoutMinutes int `json:"idle_timeout_minutes,omitempty"`
RetentionPeriodMinutes *int `json:"retention_period_minutes,omitempty"`
Ref string `json:"ref"`
Location string `json:"location"`
Machine string `json:"machine"`
DevContainerPath string `json:"devcontainer_path,omitempty"`
VSCSTarget string `json:"vscs_target,omitempty"`
VSCSTargetURL string `json:"vscs_target_url,omitempty"`
PermissionsOptOut bool `json:"multi_repo_permissions_opt_out"`
}
var errProvisioningInProgress = errors.New("provisioning in progress")
type AcceptPermissionsRequiredError struct {
Message string `json:"message"`
AllowPermissionsURL string `json:"allow_permissions_url"`
}
func (e AcceptPermissionsRequiredError) Error() string {
return e.Message
}
// startCreate starts the creation of a codespace.
// It may return success or an error, or errProvisioningInProgress indicating that the operation
// did not complete before the GitHub API's time limit for RPCs (10s), in which case the caller
@ -540,12 +775,18 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*
}
requestBody, err := json.Marshal(startCreateRequest{
RepositoryID: params.RepositoryID,
IdleTimeoutMinutes: params.IdleTimeoutMinutes,
Ref: params.Branch,
Location: params.Location,
Machine: params.Machine,
RepositoryID: params.RepositoryID,
IdleTimeoutMinutes: params.IdleTimeoutMinutes,
RetentionPeriodMinutes: params.RetentionPeriodMinutes,
Ref: params.Branch,
Location: params.Location,
Machine: params.Machine,
DevContainerPath: params.DevContainerPath,
VSCSTarget: params.VSCSTarget,
VSCSTargetURL: params.VSCSTargetURL,
PermissionsOptOut: params.PermissionsOptOut,
})
if err != nil {
return nil, fmt.Errorf("error marshaling request: %w", err)
}
@ -563,12 +804,45 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*
defer resp.Body.Close()
if resp.StatusCode == http.StatusAccepted {
return nil, errProvisioningInProgress // RPC finished before result of creation known
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var response Codespace
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
return &response, errProvisioningInProgress // RPC finished before result of creation known
} else if resp.StatusCode == http.StatusUnauthorized {
var (
ue AcceptPermissionsRequiredError
bodyCopy = &bytes.Buffer{}
r = io.TeeReader(resp.Body, bodyCopy)
)
b, err := io.ReadAll(r)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
if err := json.Unmarshal(b, &ue); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
if ue.AllowPermissionsURL != "" {
return nil, ue
}
resp.Body = io.NopCloser(bodyCopy)
return nil, api.HandleHTTPError(resp)
} else if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
@ -582,14 +856,25 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*
}
// DeleteCodespace deletes the given codespace.
func (a *API) DeleteCodespace(ctx context.Context, codespaceName string) error {
req, err := http.NewRequest(http.MethodDelete, a.githubAPI+"/user/codespaces/"+codespaceName, nil)
func (a *API) DeleteCodespace(ctx context.Context, codespaceName string, orgName string, userName string) error {
var deleteURL string
var spanName string
if orgName != "" && userName != "" {
deleteURL = fmt.Sprintf("%s/orgs/%s/members/%s/codespaces/%s", a.githubAPI, orgName, userName, codespaceName)
spanName = "/orgs/*/members/*/codespaces/*"
} else {
deleteURL = a.githubAPI + "/user/codespaces/" + codespaceName
spanName = "/user/codespaces/*"
}
req, err := http.NewRequest(http.MethodDelete, deleteURL, nil)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*")
resp, err := a.do(ctx, req, spanName)
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
@ -602,6 +887,136 @@ func (a *API) DeleteCodespace(ctx context.Context, codespaceName string) error {
return nil
}
type DevContainerEntry struct {
Path string `json:"path"`
Name string `json:"name,omitempty"`
}
// ListDevContainers returns a list of valid devcontainer.json files for the repo. Pass a negative limit to request all pages from
// the API until all devcontainer.json files have been fetched.
func (a *API) ListDevContainers(ctx context.Context, repoID int, branch string, limit int) (devcontainers []DevContainerEntry, err error) {
perPage := 100
if limit > 0 && limit < 100 {
perPage = limit
}
v := url.Values{}
v.Set("per_page", strconv.Itoa(perPage))
if branch != "" {
v.Set("ref", branch)
}
listURL := fmt.Sprintf("%s/repositories/%d/codespaces/devcontainers?%s", a.githubAPI, repoID, v.Encode())
for {
req, err := http.NewRequest(http.MethodGet, listURL, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, fmt.Sprintf("/repositories/%d/codespaces/devcontainers", repoID))
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
var response struct {
Devcontainers []DevContainerEntry `json:"devcontainers"`
}
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
nextURL := findNextPage(resp.Header.Get("Link"))
devcontainers = append(devcontainers, response.Devcontainers...)
if nextURL == "" || (limit > 0 && len(devcontainers) >= limit) {
break
}
if newPerPage := limit - len(devcontainers); limit > 0 && newPerPage < 100 {
u, _ := url.Parse(nextURL)
q := u.Query()
q.Set("per_page", strconv.Itoa(newPerPage))
u.RawQuery = q.Encode()
listURL = u.String()
} else {
listURL = nextURL
}
}
return devcontainers, nil
}
type EditCodespaceParams struct {
DisplayName string `json:"display_name,omitempty"`
IdleTimeoutMinutes int `json:"idle_timeout_minutes,omitempty"`
Machine string `json:"machine,omitempty"`
}
func (a *API) EditCodespace(ctx context.Context, codespaceName string, params *EditCodespaceParams) (*Codespace, error) {
requestBody, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("error marshaling request: %w", err)
}
req, err := http.NewRequest(http.MethodPatch, a.githubAPI+"/user/codespaces/"+codespaceName, bytes.NewBuffer(requestBody))
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.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)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var response Codespace
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
return &response, nil
}
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"`
}
@ -629,7 +1044,7 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod
return nil, api.HandleHTTPError(resp)
}
b, err := ioutil.ReadAll(resp.Body)
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
@ -647,31 +1062,6 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod
return decoded, nil
}
// AuthorizedKeys returns the public keys (in ~/.ssh/authorized_keys
// format) registered by the specified GitHub user.
func (a *API) AuthorizedKeys(ctx context.Context, user string) ([]byte, error) {
url := fmt.Sprintf("%s/%s.keys", a.githubServer, user)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := a.do(ctx, req, "/user.keys")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned %s", resp.Status)
}
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
return b, nil
}
// do executes the given request and returns the response. It creates an
// opentracing span to track the length of the request.
func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http.Response, error) {
@ -686,3 +1076,19 @@ func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http
func (a *API) setHeaders(req *http.Request) {
req.Header.Set("Accept", "application/vnd.github.v3+json")
}
// withRetry takes a generic function that sends an http request and retries
// only when the returned response has a >=500 status code.
func (a *API) withRetry(f func() (*http.Response, error)) (resp *http.Response, err error) {
for i := 0; i < 5; i++ {
resp, err = f()
if err != nil {
return nil, err
}
if resp.StatusCode < 500 {
break
}
time.Sleep(a.retryBackoff * (time.Duration(i) + 1))
}
return resp, err
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strconv"
"testing"
)
@ -65,6 +66,117 @@ func createFakeListEndpointServer(t *testing.T, initalTotal int, finalTotal int)
}))
}
func createFakeCreateEndpointServer(t *testing.T, wantStatus int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// create endpoint
if r.URL.Path == "/user/codespaces" {
body := r.Body
if body == nil {
t.Fatal("No body")
}
defer body.Close()
var params startCreateRequest
err := json.NewDecoder(body).Decode(&params)
if err != nil {
t.Fatal("error:", err)
}
if params.RepositoryID != 1 {
t.Fatal("Expected RepositoryID to be 1. Got: ", params.RepositoryID)
}
if params.IdleTimeoutMinutes != 10 {
t.Fatal("Expected IdleTimeoutMinutes to be 10. Got: ", params.IdleTimeoutMinutes)
}
if *params.RetentionPeriodMinutes != 0 {
t.Fatal("Expected RetentionPeriodMinutes to be 0. Got: ", *params.RetentionPeriodMinutes)
}
response := Codespace{
Name: "codespace-1",
}
if wantStatus == 0 {
wantStatus = http.StatusCreated
}
data, _ := json.Marshal(response)
w.WriteHeader(wantStatus)
fmt.Fprint(w, string(data))
return
}
// get endpoint hit for testing pending status
if r.URL.Path == "/user/codespaces/codespace-1" {
response := Codespace{
Name: "codespace-1",
State: CodespaceStateAvailable,
}
data, _ := json.Marshal(response)
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, string(data))
return
}
t.Fatal("Incorrect path")
}))
}
func TestCreateCodespaces(t *testing.T) {
svr := createFakeCreateEndpointServer(t, http.StatusCreated)
defer svr.Close()
api := API{
githubAPI: svr.URL,
client: &http.Client{},
}
ctx := context.TODO()
retentionPeriod := 0
params := &CreateCodespaceParams{
RepositoryID: 1,
IdleTimeoutMinutes: 10,
RetentionPeriodMinutes: &retentionPeriod,
}
codespace, err := api.CreateCodespace(ctx, params)
if err != nil {
t.Fatal(err)
}
if codespace.Name != "codespace-1" {
t.Fatalf("expected codespace-1, got %s", codespace.Name)
}
}
func TestCreateCodespaces_Pending(t *testing.T) {
svr := createFakeCreateEndpointServer(t, http.StatusAccepted)
defer svr.Close()
api := API{
githubAPI: svr.URL,
client: &http.Client{},
}
ctx := context.TODO()
retentionPeriod := 0
params := &CreateCodespaceParams{
RepositoryID: 1,
IdleTimeoutMinutes: 10,
RetentionPeriodMinutes: &retentionPeriod,
}
codespace, err := api.CreateCodespace(ctx, params)
if err != nil {
t.Fatal(err)
}
if codespace.Name != "codespace-1" {
t.Fatalf("expected codespace-1, got %s", codespace.Name)
}
}
func TestListCodespaces_limited(t *testing.T) {
svr := createFakeListEndpointServer(t, 200, 200)
defer svr.Close()
@ -74,7 +186,7 @@ func TestListCodespaces_limited(t *testing.T) {
client: &http.Client{},
}
ctx := context.TODO()
codespaces, err := api.ListCodespaces(ctx, 200)
codespaces, err := api.ListCodespaces(ctx, ListCodespacesOptions{Limit: 200})
if err != nil {
t.Fatal(err)
}
@ -99,7 +211,7 @@ func TestListCodespaces_unlimited(t *testing.T) {
client: &http.Client{},
}
ctx := context.TODO()
codespaces, err := api.ListCodespaces(ctx, -1)
codespaces, err := api.ListCodespaces(ctx, ListCodespacesOptions{})
if err != nil {
t.Fatal(err)
}
@ -114,3 +226,361 @@ func TestListCodespaces_unlimited(t *testing.T) {
t.Fatalf("expected codespace-249, got %s", codespaces[0].Name)
}
}
func TestGetRepoSuggestions(t *testing.T) {
tests := []struct {
searchText string // The input search string
queryText string // The wanted query string (based off searchText)
sort string // (Optional) The RepoSearchParameters.Sort param
maxRepos string // (Optional) The RepoSearchParameters.MaxRepos param
}{
{
searchText: "test",
queryText: "test",
},
{
searchText: "org/repo",
queryText: "repo user:org",
},
{
searchText: "org/repo/extra",
queryText: "repo/extra user:org",
},
{
searchText: "test",
queryText: "test",
sort: "stars",
maxRepos: "1000",
},
}
for _, tt := range tests {
runRepoSearchTest(t, tt.searchText, tt.queryText, tt.sort, tt.maxRepos)
}
}
func createFakeSearchReposServer(t *testing.T, wantSearchText string, wantSort string, wantPerPage string, responseRepos []*Repository) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/search/repositories" {
t.Error("Incorrect path")
return
}
query := r.URL.Query()
got := fmt.Sprintf("q=%q sort=%s per_page=%s", query.Get("q"), query.Get("sort"), query.Get("per_page"))
want := fmt.Sprintf("q=%q sort=%s per_page=%s", wantSearchText+" in:name", wantSort, wantPerPage)
if got != want {
t.Errorf("for query, got %s, want %s", got, want)
return
}
response := struct {
Items []*Repository `json:"items"`
}{
responseRepos,
}
if err := json.NewEncoder(w).Encode(response); err != nil {
t.Error(err)
}
}))
}
func runRepoSearchTest(t *testing.T, searchText, wantQueryText, wantSort, wantMaxRepos string) {
wantRepoNames := []string{"repo1", "repo2"}
apiResponseRepositories := make([]*Repository, 0)
for _, name := range wantRepoNames {
apiResponseRepositories = append(apiResponseRepositories, &Repository{FullName: name})
}
svr := createFakeSearchReposServer(t, wantQueryText, wantSort, wantMaxRepos, apiResponseRepositories)
defer svr.Close()
api := API{
githubAPI: svr.URL,
client: &http.Client{},
}
ctx := context.Background()
searchParameters := RepoSearchParameters{}
if len(wantSort) > 0 {
searchParameters.Sort = wantSort
}
if len(wantMaxRepos) > 0 {
searchParameters.MaxRepos, _ = strconv.Atoi(wantMaxRepos)
}
gotRepoNames, err := api.GetCodespaceRepoSuggestions(ctx, searchText, searchParameters)
if err != nil {
t.Fatal(err)
}
gotNamesStr := fmt.Sprintf("%v", gotRepoNames)
wantNamesStr := fmt.Sprintf("%v", wantRepoNames)
if gotNamesStr != wantNamesStr {
t.Fatalf("got repo names %s, want %s", gotNamesStr, wantNamesStr)
}
}
func TestRetries(t *testing.T) {
var callCount int
csName := "test_codespace"
handler := func(w http.ResponseWriter, r *http.Request) {
if callCount == 3 {
err := json.NewEncoder(w).Encode(Codespace{
Name: csName,
})
if err != nil {
t.Fatal(err)
}
return
}
callCount++
w.WriteHeader(502)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler(w, r) }))
t.Cleanup(srv.Close)
a := &API{
githubAPI: srv.URL,
client: &http.Client{},
}
cs, err := a.GetCodespace(context.Background(), "test", false)
if err != nil {
t.Fatal(err)
}
if callCount != 3 {
t.Fatalf("expected at least 2 retries but got %d", callCount)
}
if cs.Name != csName {
t.Fatalf("expected codespace name to be %q but got %q", csName, cs.Name)
}
callCount = 0
handler = func(w http.ResponseWriter, r *http.Request) {
callCount++
err := json.NewEncoder(w).Encode(Codespace{
Name: csName,
})
if err != nil {
t.Fatal(err)
}
}
cs, err = a.GetCodespace(context.Background(), "test", false)
if err != nil {
t.Fatal(err)
}
if callCount != 1 {
t.Fatalf("expected no retries but got %d calls", callCount)
}
if cs.Name != csName {
t.Fatalf("expected codespace name to be %q but got %q", csName, cs.Name)
}
}
func TestCodespace_ExportData(t *testing.T) {
type fields struct {
Name string
CreatedAt string
DisplayName string
LastUsedAt string
Owner User
Repository Repository
State string
GitStatus CodespaceGitStatus
Connection CodespaceConnection
Machine CodespaceMachine
}
type args struct {
fields []string
}
tests := []struct {
name string
fields fields
args args
want map[string]interface{}
}{
{
name: "just name",
fields: fields{
Name: "test",
},
args: args{
fields: []string{"name"},
},
want: map[string]interface{}{
"name": "test",
},
},
{
name: "just owner",
fields: fields{
Owner: User{
Login: "test",
},
},
args: args{
fields: []string{"owner"},
},
want: map[string]interface{}{
"owner": "test",
},
},
{
name: "just machine",
fields: fields{
Machine: CodespaceMachine{
Name: "test",
},
},
args: args{
fields: []string{"machineName"},
},
want: map[string]interface{}{
"machineName": "test",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &Codespace{
Name: tt.fields.Name,
CreatedAt: tt.fields.CreatedAt,
DisplayName: tt.fields.DisplayName,
LastUsedAt: tt.fields.LastUsedAt,
Owner: tt.fields.Owner,
Repository: tt.fields.Repository,
State: tt.fields.State,
GitStatus: tt.fields.GitStatus,
Connection: tt.fields.Connection,
Machine: tt.fields.Machine,
}
if got := c.ExportData(tt.args.fields); !reflect.DeepEqual(got, tt.want) {
t.Errorf("Codespace.ExportData() = %v, want %v", got, tt.want)
}
})
}
}
func createFakeEditServer(t *testing.T, codespaceName string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
checkPath := "/user/codespaces/" + codespaceName
if r.URL.Path != checkPath {
t.Fatal("Incorrect path")
}
if r.Method != http.MethodPatch {
t.Fatal("Incorrect method")
}
body := r.Body
if body == nil {
t.Fatal("No body")
}
defer body.Close()
var data map[string]interface{}
err := json.NewDecoder(body).Decode(&data)
if err != nil {
t.Fatal(err)
}
if data["display_name"] != "changeTo" {
t.Fatal("Incorrect display name")
}
response := Codespace{
DisplayName: "changeTo",
}
responseData, _ := json.Marshal(response)
fmt.Fprint(w, string(responseData))
}))
}
func TestAPI_EditCodespace(t *testing.T) {
type args struct {
ctx context.Context
codespaceName string
params *EditCodespaceParams
}
tests := []struct {
name string
args args
want *Codespace
wantErr bool
}{
{
name: "success",
args: args{
ctx: context.Background(),
codespaceName: "test",
params: &EditCodespaceParams{
DisplayName: "changeTo",
},
},
want: &Codespace{
DisplayName: "changeTo",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svr := createFakeEditServer(t, tt.args.codespaceName)
defer svr.Close()
a := &API{
client: &http.Client{},
githubAPI: svr.URL,
}
got, err := a.EditCodespace(tt.args.ctx, tt.args.codespaceName, tt.args.params)
if (err != nil) != tt.wantErr {
t.Errorf("API.EditCodespace() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("API.EditCodespace() = %v, want %v", got.DisplayName, tt.want.DisplayName)
}
})
}
}
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)
}
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/liveshare"
)
@ -38,17 +39,24 @@ type logger interface {
func ConnectToLiveshare(ctx context.Context, progress progressIndicator, sessionLogger logger, apiClient apiClient, codespace *api.Codespace) (sess *liveshare.Session, err error) {
if codespace.State != api.CodespaceStateAvailable {
progress.StartProgressIndicatorWithLabel("Starting codespace")
defer progress.StopProgressIndicator()
if err := apiClient.StartCodespace(ctx, codespace.Name); err != nil {
return nil, fmt.Errorf("error starting codespace: %w", err)
}
}
expBackoff := backoff.NewExponentialBackOff()
expBackoff.Multiplier = 1.1
expBackoff.MaxInterval = 10 * time.Second
expBackoff.MaxElapsedTime = 5 * time.Minute
for retries := 0; !connectionReady(codespace); retries++ {
if retries > 1 {
time.Sleep(1 * time.Second)
duration := expBackoff.NextBackOff()
time.Sleep(duration)
}
if retries == 30 {
if expBackoff.GetElapsedTime() >= expBackoff.MaxElapsedTime {
return nil, errors.New("timed out while waiting for the codespace to start")
}

View file

@ -0,0 +1,145 @@
package grpc
// gRPC client implementation to be able to connect to the gRPC server and perform the following operations:
// - Start a remote JupyterLab server
import (
"context"
"fmt"
"net"
"strconv"
"time"
"github.com/cli/cli/v2/internal/codespaces/grpc/jupyter"
"github.com/cli/cli/v2/pkg/liveshare"
"golang.org/x/crypto/ssh"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
const (
ConnectionTimeout = 5 * time.Second
RequestTimeout = 30 * time.Second
)
const (
codespacesInternalPort = 16634
codespacesInternalSessionName = "CodespacesInternal"
)
type Client struct {
conn *grpc.ClientConn
token string
listener net.Listener
jupyterClient jupyter.JupyterServerHostClient
cancelPF context.CancelFunc
}
type liveshareSession interface {
KeepAlive(string)
OpenStreamingChannel(context.Context, liveshare.ChannelID) (ssh.Channel, error)
StartSharing(context.Context, string, int) (liveshare.ChannelID, error)
}
// Finds a free port to listen on and creates a new gRPC client that connects to that port
func Connect(ctx context.Context, session liveshareSession, token string) (*Client, error) {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", 0))
if err != nil {
return nil, fmt.Errorf("failed to listen to local port over tcp: %w", err)
}
localAddress := fmt.Sprintf("127.0.0.1:%d", listener.Addr().(*net.TCPAddr).Port)
client := &Client{
token: token,
listener: listener,
}
// Create a cancelable context to be able to cancel background tasks
// if we encounter an error while connecting to the gRPC server
connectctx, cancel := context.WithCancel(context.Background())
defer func() {
if err != nil {
cancel()
}
}()
ch := make(chan error, 2) // Buffered channel to ensure we don't block on the goroutine
// Ensure we close the port forwarder if we encounter an error
// or once the gRPC connection is closed. pfcancel is retained
// to close the PF whenever we close the gRPC connection.
pfctx, pfcancel := context.WithCancel(connectctx)
client.cancelPF = pfcancel
// Tunnel the remote gRPC server port to the local port
go func() {
fwd := liveshare.NewPortForwarder(session, codespacesInternalSessionName, codespacesInternalPort, true)
ch <- fwd.ForwardToListener(pfctx, listener)
}()
var conn *grpc.ClientConn
go func() {
// Attempt to connect to the port
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
}
conn, err = grpc.DialContext(connectctx, localAddress, opts...)
ch <- err // nil if we successfully connected
}()
// Wait for the connection to be established or for the context to be cancelled
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-ch:
if err != nil {
return nil, err
}
}
client.conn = conn
client.jupyterClient = jupyter.NewJupyterServerHostClient(conn)
return client, nil
}
// Closes the gRPC connection
func (g *Client) Close() error {
g.cancelPF()
// Closing the local listener effectively closes the gRPC connection
if err := g.listener.Close(); err != nil {
g.conn.Close() // If we fail to close the listener, explicitly close the gRPC connection and ignore any error
return fmt.Errorf("failed to close local tcp port listener: %w", err)
}
return nil
}
// Appends the authentication token to the gRPC context
func (g *Client) appendMetadata(ctx context.Context) context.Context {
return metadata.AppendToOutgoingContext(ctx, "Authorization", "Bearer "+g.token)
}
// Starts a remote JupyterLab server to allow the user to connect to the codespace via JupyterLab in their browser
func (g *Client) StartJupyterServer(ctx context.Context) (port int, serverUrl string, err error) {
ctx = g.appendMetadata(ctx)
response, err := g.jupyterClient.GetRunningServer(ctx, &jupyter.GetRunningServerRequest{})
if err != nil {
return 0, "", fmt.Errorf("failed to invoke JupyterLab RPC: %w", err)
}
if !response.Result {
return 0, "", fmt.Errorf("failed to start JupyterLab: %s", response.Message)
}
port, err = strconv.Atoi(response.Port)
if err != nil {
return 0, "", fmt.Errorf("failed to parse JupyterLab port: %w", err)
}
return port, response.ServerUrl, err
}

View file

@ -0,0 +1,84 @@
package grpc
import (
"context"
"fmt"
"log"
"os"
"testing"
grpctest "github.com/cli/cli/v2/internal/codespaces/grpc/test"
)
func startServer(t *testing.T) {
t.Helper()
if os.Getenv("GITHUB_ACTIONS") == "true" {
t.Skip("fails intermittently in CI: https://github.com/cli/cli/issues/5663")
}
ctx, cancel := context.WithCancel(context.Background())
// Start the gRPC server in the background
go func() {
err := grpctest.StartServer(ctx)
if err != nil && err != context.Canceled {
log.Println(fmt.Errorf("error starting test server: %v", err))
}
}()
// Stop the gRPC server when the test is done
t.Cleanup(func() {
cancel()
})
}
func connect(t *testing.T) (client *Client) {
t.Helper()
client, err := Connect(context.Background(), &grpctest.Session{}, "token")
if err != nil {
t.Fatalf("error connecting to internal server: %v", err)
}
t.Cleanup(func() {
client.Close()
})
return client
}
// Test that the gRPC client returns the correct port and URL when the JupyterLab server starts successfully
func TestStartJupyterServerSuccess(t *testing.T) {
startServer(t)
client := connect(t)
port, url, err := client.StartJupyterServer(context.Background())
if err != nil {
t.Fatalf("expected %v, got %v", nil, err)
}
if port != grpctest.JupyterPort {
t.Fatalf("expected %d, got %d", grpctest.JupyterPort, port)
}
if url != grpctest.JupyterServerUrl {
t.Fatalf("expected %s, got %s", grpctest.JupyterServerUrl, url)
}
}
// Test that the gRPC client returns an error when the JupyterLab server fails to start
func TestStartJupyterServerFailure(t *testing.T) {
startServer(t)
client := connect(t)
grpctest.JupyterMessage = "error message"
grpctest.JupyterResult = false
errorMessage := fmt.Sprintf("failed to start JupyterLab: %s", grpctest.JupyterMessage)
port, url, err := client.StartJupyterServer(context.Background())
if err.Error() != errorMessage {
t.Fatalf("expected %v, got %v", errorMessage, err)
}
if port != 0 {
t.Fatalf("expected %d, got %d", 0, port)
}
if url != "" {
t.Fatalf("expected %s, got %s", "", url)
}
}

View file

@ -0,0 +1,16 @@
# Protocol Buffers for Codespaces
Instructions for generating and adding gRPC protocol buffers.
## Generate Protocol Buffers
1. [Download `protoc`](https://grpc.io/docs/protoc-installation/)
2. [Download protocol compiler plugins for Go](https://grpc.io/docs/languages/go/quickstart/)
3. Run `./generate.sh` from the `internal/codespaces/grpc` directory
## Add New Protocol Buffers
1. Download a `.proto` contract from the service repo
2. Create a new directory and copy the `.proto` to it
3. Update `generate.sh` to include the include the new `.proto`
4. Follow the instructions to [Generate Protocol Buffers](#generate-protocol-buffers)

View file

@ -0,0 +1,26 @@
#!/bin/bash
set -e
if ! protoc --version; then
echo 'ERROR: protoc is not on your PATH'
exit 1
fi
if ! protoc-gen-go --version; then
echo 'ERROR: protoc-gen-go is not on your PATH'
exit 1
fi
if ! protoc-gen-go-grpc --version; then
echo 'ERROR: protoc-gen-go-grpc is not on your PATH'
fi
function generate {
local contract="$1"
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative "$contract"
echo "Generated protocol buffers for $contract"
}
generate jupyter/JupyterServerHostService.v1.proto
echo 'Done!'

View file

@ -0,0 +1,241 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.28.1
// protoc v3.12.4
// source: jupyter/JupyterServerHostService.v1.proto
package jupyter
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type GetRunningServerRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *GetRunningServerRequest) Reset() {
*x = GetRunningServerRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_jupyter_JupyterServerHostService_v1_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GetRunningServerRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetRunningServerRequest) ProtoMessage() {}
func (x *GetRunningServerRequest) ProtoReflect() protoreflect.Message {
mi := &file_jupyter_JupyterServerHostService_v1_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetRunningServerRequest.ProtoReflect.Descriptor instead.
func (*GetRunningServerRequest) Descriptor() ([]byte, []int) {
return file_jupyter_JupyterServerHostService_v1_proto_rawDescGZIP(), []int{0}
}
type GetRunningServerResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Result bool `protobuf:"varint,1,opt,name=Result,proto3" json:"Result,omitempty"`
Message string `protobuf:"bytes,2,opt,name=Message,proto3" json:"Message,omitempty"`
Port string `protobuf:"bytes,3,opt,name=Port,proto3" json:"Port,omitempty"`
ServerUrl string `protobuf:"bytes,4,opt,name=ServerUrl,proto3" json:"ServerUrl,omitempty"`
}
func (x *GetRunningServerResponse) Reset() {
*x = GetRunningServerResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_jupyter_JupyterServerHostService_v1_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GetRunningServerResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetRunningServerResponse) ProtoMessage() {}
func (x *GetRunningServerResponse) ProtoReflect() protoreflect.Message {
mi := &file_jupyter_JupyterServerHostService_v1_proto_msgTypes[1]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetRunningServerResponse.ProtoReflect.Descriptor instead.
func (*GetRunningServerResponse) Descriptor() ([]byte, []int) {
return file_jupyter_JupyterServerHostService_v1_proto_rawDescGZIP(), []int{1}
}
func (x *GetRunningServerResponse) GetResult() bool {
if x != nil {
return x.Result
}
return false
}
func (x *GetRunningServerResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *GetRunningServerResponse) GetPort() string {
if x != nil {
return x.Port
}
return ""
}
func (x *GetRunningServerResponse) GetServerUrl() string {
if x != nil {
return x.ServerUrl
}
return ""
}
var File_jupyter_JupyterServerHostService_v1_proto protoreflect.FileDescriptor
var file_jupyter_JupyterServerHostService_v1_proto_rawDesc = []byte{
0x0a, 0x29, 0x6a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x2f, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65,
0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69,
0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x2b, 0x43, 0x6f, 0x64,
0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, 0x75, 0x70,
0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65,
0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x22, 0x19, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x52,
0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x22, 0x7e, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e,
0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x16, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55,
0x72, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
0x55, 0x72, 0x6c, 0x32, 0xb5, 0x01, 0x0a, 0x11, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x12, 0x9f, 0x01, 0x0a, 0x10, 0x47, 0x65,
0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x44,
0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63,
0x2e, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f,
0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74,
0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x45, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65,
0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65,
0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e,
0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72,
0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0b, 0x5a, 0x09, 0x2e,
0x2f, 0x6a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_jupyter_JupyterServerHostService_v1_proto_rawDescOnce sync.Once
file_jupyter_JupyterServerHostService_v1_proto_rawDescData = file_jupyter_JupyterServerHostService_v1_proto_rawDesc
)
func file_jupyter_JupyterServerHostService_v1_proto_rawDescGZIP() []byte {
file_jupyter_JupyterServerHostService_v1_proto_rawDescOnce.Do(func() {
file_jupyter_JupyterServerHostService_v1_proto_rawDescData = protoimpl.X.CompressGZIP(file_jupyter_JupyterServerHostService_v1_proto_rawDescData)
})
return file_jupyter_JupyterServerHostService_v1_proto_rawDescData
}
var file_jupyter_JupyterServerHostService_v1_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_jupyter_JupyterServerHostService_v1_proto_goTypes = []interface{}{
(*GetRunningServerRequest)(nil), // 0: Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerRequest
(*GetRunningServerResponse)(nil), // 1: Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerResponse
}
var file_jupyter_JupyterServerHostService_v1_proto_depIdxs = []int32{
0, // 0: Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost.GetRunningServer:input_type -> Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerRequest
1, // 1: Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost.GetRunningServer:output_type -> Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerResponse
1, // [1:2] is the sub-list for method output_type
0, // [0:1] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_jupyter_JupyterServerHostService_v1_proto_init() }
func file_jupyter_JupyterServerHostService_v1_proto_init() {
if File_jupyter_JupyterServerHostService_v1_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_jupyter_JupyterServerHostService_v1_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*GetRunningServerRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_jupyter_JupyterServerHostService_v1_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*GetRunningServerResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_jupyter_JupyterServerHostService_v1_proto_rawDesc,
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_jupyter_JupyterServerHostService_v1_proto_goTypes,
DependencyIndexes: file_jupyter_JupyterServerHostService_v1_proto_depIdxs,
MessageInfos: file_jupyter_JupyterServerHostService_v1_proto_msgTypes,
}.Build()
File_jupyter_JupyterServerHostService_v1_proto = out.File
file_jupyter_JupyterServerHostService_v1_proto_rawDesc = nil
file_jupyter_JupyterServerHostService_v1_proto_goTypes = nil
file_jupyter_JupyterServerHostService_v1_proto_depIdxs = nil
}

View file

@ -0,0 +1,19 @@
syntax = "proto3";
option go_package = "./jupyter";
package Codespaces.Grpc.JupyterServerHostService.v1;
service JupyterServerHost {
rpc GetRunningServer (GetRunningServerRequest) returns (GetRunningServerResponse);
}
message GetRunningServerRequest {
}
message GetRunningServerResponse {
bool Result = 1;
string Message = 2;
string Port = 3;
string ServerUrl = 4;
}

View file

@ -0,0 +1,105 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.2.0
// - protoc v3.12.4
// source: jupyter/JupyterServerHostService.v1.proto
package jupyter
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.32.0 or later.
const _ = grpc.SupportPackageIsVersion7
// JupyterServerHostClient is the client API for JupyterServerHost service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type JupyterServerHostClient interface {
GetRunningServer(ctx context.Context, in *GetRunningServerRequest, opts ...grpc.CallOption) (*GetRunningServerResponse, error)
}
type jupyterServerHostClient struct {
cc grpc.ClientConnInterface
}
func NewJupyterServerHostClient(cc grpc.ClientConnInterface) JupyterServerHostClient {
return &jupyterServerHostClient{cc}
}
func (c *jupyterServerHostClient) GetRunningServer(ctx context.Context, in *GetRunningServerRequest, opts ...grpc.CallOption) (*GetRunningServerResponse, error) {
out := new(GetRunningServerResponse)
err := c.cc.Invoke(ctx, "/Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost/GetRunningServer", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// JupyterServerHostServer is the server API for JupyterServerHost service.
// All implementations must embed UnimplementedJupyterServerHostServer
// for forward compatibility
type JupyterServerHostServer interface {
GetRunningServer(context.Context, *GetRunningServerRequest) (*GetRunningServerResponse, error)
mustEmbedUnimplementedJupyterServerHostServer()
}
// UnimplementedJupyterServerHostServer must be embedded to have forward compatible implementations.
type UnimplementedJupyterServerHostServer struct {
}
func (UnimplementedJupyterServerHostServer) GetRunningServer(context.Context, *GetRunningServerRequest) (*GetRunningServerResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRunningServer not implemented")
}
func (UnimplementedJupyterServerHostServer) mustEmbedUnimplementedJupyterServerHostServer() {}
// UnsafeJupyterServerHostServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to JupyterServerHostServer will
// result in compilation errors.
type UnsafeJupyterServerHostServer interface {
mustEmbedUnimplementedJupyterServerHostServer()
}
func RegisterJupyterServerHostServer(s grpc.ServiceRegistrar, srv JupyterServerHostServer) {
s.RegisterService(&JupyterServerHost_ServiceDesc, srv)
}
func _JupyterServerHost_GetRunningServer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRunningServerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(JupyterServerHostServer).GetRunningServer(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost/GetRunningServer",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(JupyterServerHostServer).GetRunningServer(ctx, req.(*GetRunningServerRequest))
}
return interceptor(ctx, in, info, handler)
}
// JupyterServerHost_ServiceDesc is the grpc.ServiceDesc for JupyterServerHost service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var JupyterServerHost_ServiceDesc = grpc.ServiceDesc{
ServiceName: "Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost",
HandlerType: (*JupyterServerHostServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetRunningServer",
Handler: _JupyterServerHost_GetRunningServer_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "jupyter/JupyterServerHostService.v1.proto",
}

View file

@ -0,0 +1,34 @@
package test
import (
"io"
"net"
)
type Channel struct {
conn net.Conn
}
func (c *Channel) Read(data []byte) (int, error) {
return c.conn.Read(data)
}
func (c *Channel) Write(data []byte) (int, error) {
return c.conn.Write(data)
}
func (c *Channel) Close() error {
return c.conn.Close()
}
func (c *Channel) CloseWrite() error {
return nil
}
func (c *Channel) SendRequest(name string, wantReply bool, payload []byte) (bool, error) {
return false, nil
}
func (c *Channel) Stderr() io.ReadWriter {
return nil
}

View file

@ -0,0 +1,62 @@
package test
import (
"context"
"fmt"
"net"
"strconv"
"github.com/cli/cli/v2/internal/codespaces/grpc/jupyter"
"google.golang.org/grpc"
)
const (
ServerPort = 50051
)
var (
JupyterPort = 1234
JupyterServerUrl = "http://localhost:1234?token=1234"
JupyterMessage = ""
JupyterResult = true
)
type server struct {
jupyter.UnimplementedJupyterServerHostServer
}
func (s *server) GetRunningServer(ctx context.Context, in *jupyter.GetRunningServerRequest) (*jupyter.GetRunningServerResponse, error) {
return &jupyter.GetRunningServerResponse{
Port: strconv.Itoa(JupyterPort),
ServerUrl: JupyterServerUrl,
Message: JupyterMessage,
Result: JupyterResult,
}, nil
}
// Starts the mock gRPC server listening on port 50051
func StartServer(ctx context.Context) error {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort))
if err != nil {
return fmt.Errorf("failed to listen: %v", err)
}
defer listener.Close()
s := grpc.NewServer()
jupyter.RegisterJupyterServerHostServer(s, &server{})
ch := make(chan error, 1)
go func() {
if err := s.Serve(listener); err != nil {
ch <- fmt.Errorf("failed to serve: %v", err)
}
}()
select {
case <-ctx.Done():
s.Stop()
return ctx.Err()
case err := <-ch:
return err
}
}

View file

@ -0,0 +1,31 @@
package test
import (
"context"
"fmt"
"net"
"github.com/cli/cli/v2/pkg/liveshare"
"golang.org/x/crypto/ssh"
)
type Session struct {
channel ssh.Channel
}
func (s *Session) KeepAlive(reason string) {
}
func (s *Session) StartSharing(ctx context.Context, sessionName string, port int) (liveshare.ChannelID, error) {
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort))
if err != nil {
return liveshare.ChannelID{}, err
}
s.channel = &Channel{conn}
return liveshare.ChannelID{}, nil
}
// Creates mock SSH channel connected to the mock gRPC server
func (s *Session) OpenStreamingChannel(ctx context.Context, id liveshare.ChannelID) (ssh.Channel, error) {
return s.channel, nil
}

View file

@ -7,6 +7,8 @@ import (
"os/exec"
"strconv"
"strings"
"github.com/cli/safeexec"
)
type printer interface {
@ -29,30 +31,18 @@ func Shell(ctx context.Context, p printer, sshArgs []string, port int, destinati
return cmd.Run()
}
// Copy runs an scp command over the specified port. The arguments may
// include flags and non-flags, optionally separated by "--".
// Copy runs an scp command over the specified port. scpArgs should contain both scp flags
// as well as the list of files to copy, with the flags first.
//
// Remote files indicated by a "remote:" prefix are resolved relative
// to the remote user's home directory, and are subject to shell expansion
// on the remote host; see https://lwn.net/Articles/835962/.
func Copy(ctx context.Context, scpArgs []string, port int, destination string) error {
// Beware: invalid syntax causes scp to exit 1 with
// no error message, so don't let that happen.
cmd := exec.CommandContext(ctx, "scp",
"-P", strconv.Itoa(port),
"-o", "NoHostAuthenticationForLocalhost=yes",
"-C", // compression
)
for _, arg := range scpArgs {
// Replace "remote:" prefix with (e.g.) "root@localhost:".
if rest := strings.TrimPrefix(arg, "remote:"); rest != arg {
arg = destination + ":" + rest
}
cmd.Args = append(cmd.Args, arg)
cmd, err := newSCPCommand(ctx, port, destination, scpArgs)
if err != nil {
return fmt.Errorf("failed to create scp command: %w", err)
}
cmd.Stdin = nil
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd.Run()
}
@ -66,7 +56,11 @@ func NewRemoteCommand(ctx context.Context, tunnelPort int, destination string, s
// newSSHCommand populates an exec.Cmd to run a command (or if blank,
// an interactive shell) over ssh.
func newSSHCommand(ctx context.Context, port int, dst string, cmdArgs []string) (*exec.Cmd, []string, error) {
connArgs := []string{"-p", strconv.Itoa(port), "-o", "NoHostAuthenticationForLocalhost=yes"}
connArgs := []string{
"-p", strconv.Itoa(port),
"-o", "NoHostAuthenticationForLocalhost=yes",
"-o", "PasswordAuthentication=no",
}
// The ssh command syntax is: ssh [flags] user@host command [args...]
// There is no way to specify the user@host destination as a flag.
@ -87,7 +81,12 @@ func newSSHCommand(ctx context.Context, port int, dst string, cmdArgs []string)
cmdArgs = append(cmdArgs, command...)
}
cmd := exec.CommandContext(ctx, "ssh", cmdArgs...)
exe, err := safeexec.LookPath("ssh")
if err != nil {
return nil, nil, fmt.Errorf("failed to execute ssh: %w", err)
}
cmd := exec.CommandContext(ctx, exe, cmdArgs...)
cmd.Stdout = os.Stdout
cmd.Stdin = os.Stdin
cmd.Stderr = os.Stderr
@ -95,9 +94,60 @@ func newSSHCommand(ctx context.Context, port int, dst string, cmdArgs []string)
return cmd, connArgs, nil
}
// parseSSHArgs parses SSH arguments into two distinct slices of flags and command.
// It returns an error if a unary flag is provided without an argument.
func parseSSHArgs(args []string) (cmdArgs, command []string, err error) {
return parseArgs(args, "bcDeFIiLlmOopRSWw")
}
// newSCPCommand populates an exec.Cmd to run an scp command for the files specified in cmdArgs.
// cmdArgs is parsed such that scp flags precede the files to copy in the command.
// For example: scp -F ./config local/file remote:file
func newSCPCommand(ctx context.Context, port int, dst string, cmdArgs []string) (*exec.Cmd, error) {
connArgs := []string{
"-P", strconv.Itoa(port),
"-o", "NoHostAuthenticationForLocalhost=yes",
"-o", "PasswordAuthentication=no",
"-C", // compression
}
cmdArgs, command, err := parseSCPArgs(cmdArgs)
if err != nil {
return nil, err
}
cmdArgs = append(cmdArgs, connArgs...)
for _, arg := range command {
// Replace "remote:" prefix with (e.g.) "root@localhost:".
if rest := strings.TrimPrefix(arg, "remote:"); rest != arg {
arg = dst + ":" + rest
}
cmdArgs = append(cmdArgs, arg)
}
exe, err := safeexec.LookPath("scp")
if err != nil {
return nil, fmt.Errorf("failed to execute scp: %w", err)
}
// Beware: invalid syntax causes scp to exit 1 with
// no error message, so don't let that happen.
cmd := exec.CommandContext(ctx, exe, cmdArgs...)
cmd.Stdin = nil
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
return cmd, nil
}
func parseSCPArgs(args []string) (cmdArgs, command []string, err error) {
return parseArgs(args, "cFiJloPS")
}
// parseArgs parses arguments into two distinct slices of flags and command. Parsing stops
// as soon as a non-flag argument is found assuming the remaining arguments are the command.
// It returns an error if a unary flag is provided without an argument.
func parseArgs(args []string, unaryFlags string) (cmdArgs, command []string, err error) {
for i := 0; i < len(args); i++ {
arg := args[i]
@ -108,9 +158,9 @@ func parseSSHArgs(args []string) (cmdArgs, command []string, err error) {
}
cmdArgs = append(cmdArgs, arg)
if len(arg) == 2 && strings.Contains("bcDeFIiLlmOopRSWw", arg[1:2]) {
if len(arg) == 2 && strings.Contains(unaryFlags, arg[1:2]) {
if i++; i == len(args) {
return nil, nil, fmt.Errorf("ssh flag: %s requires an argument", arg)
return nil, nil, fmt.Errorf("flag: %s requires an argument", arg)
}
cmdArgs = append(cmdArgs, args[i])

View file

@ -5,15 +5,15 @@ import (
"testing"
)
func TestParseSSHArgs(t *testing.T) {
type testCase struct {
Args []string
ParsedArgs []string
Command []string
Error string
}
type parseTestCase struct {
Args []string
ParsedArgs []string
Command []string
Error string
}
testCases := []testCase{
func TestParseSSHArgs(t *testing.T) {
testCases := []parseTestCase{
{}, // empty test case
{
Args: []string{"-X", "-Y"},
@ -69,37 +69,85 @@ func TestParseSSHArgs(t *testing.T) {
Args: []string{"-b"},
ParsedArgs: nil,
Command: nil,
Error: "ssh flag: -b requires an argument",
Error: "flag: -b requires an argument",
},
}
for _, tcase := range testCases {
args, command, err := parseSSHArgs(tcase.Args)
if tcase.Error != "" {
if err == nil {
t.Errorf("expected error and got nil: %#v", tcase)
}
if err.Error() != tcase.Error {
t.Errorf("error does not match expected error, got: '%s', expected: '%s'", err.Error(), tcase.Error)
}
continue
}
if err != nil {
t.Errorf("unexpected error: %v on test case: %#v", err, tcase)
continue
}
argsStr, parsedArgsStr := fmt.Sprintf("%s", args), fmt.Sprintf("%s", tcase.ParsedArgs)
if argsStr != parsedArgsStr {
t.Errorf("args do not match parsed args. got: '%s', expected: '%s'", argsStr, parsedArgsStr)
}
commandStr, parsedCommandStr := fmt.Sprintf("%s", command), fmt.Sprintf("%s", tcase.Command)
if commandStr != parsedCommandStr {
t.Errorf("command does not match parsed command. got: '%s', expected: '%s'", commandStr, parsedCommandStr)
}
checkParseResult(t, tcase, args, command, err)
}
}
func TestParseSCPArgs(t *testing.T) {
testCases := []parseTestCase{
{}, // empty test case
{
Args: []string{"-X", "-Y"},
ParsedArgs: []string{"-X", "-Y"},
Command: nil,
},
{
Args: []string{"-X", "-Y", "-o", "someoption=test"},
ParsedArgs: []string{"-X", "-Y", "-o", "someoption=test"},
Command: nil,
},
{
Args: []string{"-X", "-Y", "-o", "someoption=test", "local/file", "remote:file"},
ParsedArgs: []string{"-X", "-Y", "-o", "someoption=test"},
Command: []string{"local/file", "remote:file"},
},
{
Args: []string{"-X", "-Y", "-o", "someoption=test", "local/file", "remote:file"},
ParsedArgs: []string{"-X", "-Y", "-o", "someoption=test"},
Command: []string{"local/file", "remote:file"},
},
{
Args: []string{"local/file", "remote:file"},
ParsedArgs: []string{},
Command: []string{"local/file", "remote:file"},
},
{
Args: []string{"-c"},
ParsedArgs: nil,
Command: nil,
Error: "flag: -c requires an argument",
},
}
for _, tcase := range testCases {
args, command, err := parseSCPArgs(tcase.Args)
checkParseResult(t, tcase, args, command, err)
}
}
func checkParseResult(t *testing.T, tcase parseTestCase, gotArgs, gotCmd []string, gotErr error) {
if tcase.Error != "" {
if gotErr == nil {
t.Errorf("expected error and got nil: %#v", tcase)
}
if gotErr.Error() != tcase.Error {
t.Errorf("error does not match expected error, got: '%s', expected: '%s'", gotErr.Error(), tcase.Error)
}
return
}
if gotErr != nil {
t.Errorf("unexpected error: %v on test case: %#v", gotErr, tcase)
return
}
argsStr, parsedArgsStr := fmt.Sprintf("%s", gotArgs), fmt.Sprintf("%s", tcase.ParsedArgs)
if argsStr != parsedArgsStr {
t.Errorf("args do not match parsed args. got: '%s', expected: '%s'", argsStr, parsedArgsStr)
}
commandStr, parsedCommandStr := fmt.Sprintf("%s", gotCmd), fmt.Sprintf("%s", tcase.Command)
if commandStr != parsedCommandStr {
t.Errorf("command does not match parsed command. got: '%s', expected: '%s'", commandStr, parsedCommandStr)
}
}

View file

@ -5,13 +5,13 @@ import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"io"
"log"
"net"
"strings"
"time"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/liveshare"
)
@ -19,7 +19,7 @@ import (
type PostCreateStateStatus string
func (p PostCreateStateStatus) String() string {
return strings.Title(string(p))
return text.Title(string(p))
}
const (
@ -39,7 +39,7 @@ type PostCreateState struct {
// and calls the supplied poller for each batch of state changes.
// It runs until it encounters an error, including cancellation of the context.
func PollPostCreateStates(ctx context.Context, progress progressIndicator, apiClient apiClient, codespace *api.Codespace, poller func([]PostCreateState)) (err error) {
noopLogger := log.New(ioutil.Discard, "", 0)
noopLogger := log.New(io.Discard, "", 0)
session, err := ConnectToLiveshare(ctx, progress, noopLogger, apiClient, codespace)
if err != nil {

View file

@ -1,60 +0,0 @@
package config
import (
"fmt"
)
type AliasConfig struct {
ConfigMap
Parent Config
}
func (a *AliasConfig) Get(alias string) (string, bool) {
if a.Empty() {
return "", false
}
value, _ := a.GetStringValue(alias)
return value, value != ""
}
func (a *AliasConfig) Add(alias, expansion string) error {
err := a.SetStringValue(alias, expansion)
if err != nil {
return fmt.Errorf("failed to update config: %w", err)
}
err = a.Parent.Write()
if err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
func (a *AliasConfig) Delete(alias string) error {
a.RemoveEntry(alias)
err := a.Parent.Write()
if err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
func (a *AliasConfig) All() map[string]string {
out := map[string]string{}
if a.Empty() {
return out
}
for i := 0; i < len(a.Root.Content)-1; i += 2 {
key := a.Root.Content[i].Value
value := a.Root.Content[i+1].Value
out[key] = value
}
return out
}

224
internal/config/config.go Normal file
View file

@ -0,0 +1,224 @@
package config
import (
"os"
"path/filepath"
ghAuth "github.com/cli/go-gh/pkg/auth"
ghConfig "github.com/cli/go-gh/pkg/config"
)
const (
hosts = "hosts"
aliases = "aliases"
)
// This interface describes interacting with some persistent configuration for gh.
//go:generate moq -rm -out config_mock.go . Config
type Config interface {
AuthToken(string) (string, string)
Get(string, string) (string, error)
GetOrDefault(string, string) (string, error)
Set(string, string, string)
UnsetHost(string)
Hosts() []string
DefaultHost() (string, string)
Aliases() *AliasConfig
Write() error
}
func NewConfig() (Config, error) {
c, err := ghConfig.Read()
if err != nil {
return nil, err
}
return &cfg{c}, nil
}
// Implements Config interface
type cfg struct {
cfg *ghConfig.Config
}
func (c *cfg) AuthToken(hostname string) (string, string) {
return ghAuth.TokenForHost(hostname)
}
func (c *cfg) Get(hostname, key string) (string, error) {
if hostname != "" {
val, err := c.cfg.Get([]string{hosts, hostname, key})
if err == nil {
return val, err
}
}
return c.cfg.Get([]string{key})
}
func (c *cfg) GetOrDefault(hostname, key string) (string, error) {
var val string
var err error
if hostname != "" {
val, err = c.cfg.Get([]string{hosts, hostname, key})
if err == nil {
return val, err
}
}
val, err = c.cfg.Get([]string{key})
if err == nil {
return val, err
}
if defaultExists(key) {
return defaultFor(key), nil
}
return val, err
}
func (c *cfg) Set(hostname, key, value string) {
if hostname == "" {
c.cfg.Set([]string{key}, value)
return
}
c.cfg.Set([]string{hosts, hostname, key}, value)
}
func (c *cfg) UnsetHost(hostname string) {
if hostname == "" {
return
}
_ = c.cfg.Remove([]string{hosts, hostname})
}
func (c *cfg) Hosts() []string {
return ghAuth.KnownHosts()
}
func (c *cfg) DefaultHost() (string, string) {
return ghAuth.DefaultHost()
}
func (c *cfg) Aliases() *AliasConfig {
return &AliasConfig{cfg: c.cfg}
}
func (c *cfg) Write() error {
return ghConfig.Write(c.cfg)
}
func defaultFor(key string) string {
for _, co := range configOptions {
if co.Key == key {
return co.DefaultValue
}
}
return ""
}
func defaultExists(key string) bool {
for _, co := range configOptions {
if co.Key == key {
return true
}
}
return false
}
type AliasConfig struct {
cfg *ghConfig.Config
}
func (a *AliasConfig) Get(alias string) (string, error) {
return a.cfg.Get([]string{aliases, alias})
}
func (a *AliasConfig) Add(alias, expansion string) {
a.cfg.Set([]string{aliases, alias}, expansion)
}
func (a *AliasConfig) Delete(alias string) error {
return a.cfg.Remove([]string{aliases, alias})
}
func (a *AliasConfig) All() map[string]string {
out := map[string]string{}
keys, err := a.cfg.Keys([]string{aliases})
if err != nil {
return out
}
for _, key := range keys {
val, _ := a.cfg.Get([]string{aliases, key})
out[key] = val
}
return out
}
type ConfigOption struct {
Key string
Description string
DefaultValue string
AllowedValues []string
}
var configOptions = []ConfigOption{
{
Key: "git_protocol",
Description: "the protocol to use for git clone and push operations",
DefaultValue: "https",
AllowedValues: []string{"https", "ssh"},
},
{
Key: "editor",
Description: "the text editor program to use for authoring text",
DefaultValue: "",
},
{
Key: "prompt",
Description: "toggle interactive prompting in the terminal",
DefaultValue: "enabled",
AllowedValues: []string{"enabled", "disabled"},
},
{
Key: "pager",
Description: "the terminal pager program to send standard output to",
DefaultValue: "",
},
{
Key: "http_unix_socket",
Description: "the path to a Unix socket through which to make an HTTP connection",
DefaultValue: "",
},
{
Key: "browser",
Description: "the web browser to use for opening URLs",
DefaultValue: "",
},
}
func ConfigOptions() []ConfigOption {
return configOptions
}
func HomeDirPath(subdir string) (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
newPath := filepath.Join(homeDir, subdir)
return newPath, nil
}
func StateDir() string {
return ghConfig.StateDir()
}
func DataDir() string {
return ghConfig.DataDir()
}
func ConfigDir() string {
return ghConfig.ConfigDir()
}

View file

@ -1,349 +0,0 @@
package config
import (
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"runtime"
"syscall"
"gopkg.in/yaml.v3"
)
const (
GH_CONFIG_DIR = "GH_CONFIG_DIR"
XDG_CONFIG_HOME = "XDG_CONFIG_HOME"
XDG_STATE_HOME = "XDG_STATE_HOME"
XDG_DATA_HOME = "XDG_DATA_HOME"
APP_DATA = "AppData"
LOCAL_APP_DATA = "LocalAppData"
)
// Config path precedence
// 1. GH_CONFIG_DIR
// 2. XDG_CONFIG_HOME
// 3. AppData (windows only)
// 4. HOME
func ConfigDir() string {
var path string
if a := os.Getenv(GH_CONFIG_DIR); a != "" {
path = a
} else if b := os.Getenv(XDG_CONFIG_HOME); b != "" {
path = filepath.Join(b, "gh")
} else if c := os.Getenv(APP_DATA); runtime.GOOS == "windows" && c != "" {
path = filepath.Join(c, "GitHub CLI")
} else {
d, _ := os.UserHomeDir()
path = filepath.Join(d, ".config", "gh")
}
// If the path does not exist and the GH_CONFIG_DIR flag is not set try
// migrating config from default paths.
if !dirExists(path) && os.Getenv(GH_CONFIG_DIR) == "" {
_ = autoMigrateConfigDir(path)
}
return path
}
// State path precedence
// 1. XDG_STATE_HOME
// 2. LocalAppData (windows only)
// 3. HOME
func StateDir() string {
var path string
if a := os.Getenv(XDG_STATE_HOME); a != "" {
path = filepath.Join(a, "gh")
} else if b := os.Getenv(LOCAL_APP_DATA); runtime.GOOS == "windows" && b != "" {
path = filepath.Join(b, "GitHub CLI")
} else {
c, _ := os.UserHomeDir()
path = filepath.Join(c, ".local", "state", "gh")
}
// If the path does not exist try migrating state from default paths
if !dirExists(path) {
_ = autoMigrateStateDir(path)
}
return path
}
// Data path precedence
// 1. XDG_DATA_HOME
// 2. LocalAppData (windows only)
// 3. HOME
func DataDir() string {
var path string
if a := os.Getenv(XDG_DATA_HOME); a != "" {
path = filepath.Join(a, "gh")
} else if b := os.Getenv(LOCAL_APP_DATA); runtime.GOOS == "windows" && b != "" {
path = filepath.Join(b, "GitHub CLI")
} else {
c, _ := os.UserHomeDir()
path = filepath.Join(c, ".local", "share", "gh")
}
return path
}
var errSamePath = errors.New("same path")
var errNotExist = errors.New("not exist")
// Check default path, os.UserHomeDir, for existing configs
// If configs exist then move them to newPath
func autoMigrateConfigDir(newPath string) error {
path, err := os.UserHomeDir()
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
return migrateDir(oldPath, newPath)
}
return errNotExist
}
// Check default path, os.UserHomeDir, for existing state file (state.yml)
// If state file exist then move it to newPath
func autoMigrateStateDir(newPath string) error {
path, err := os.UserHomeDir()
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
return migrateFile(oldPath, newPath, "state.yml")
}
return errNotExist
}
func migrateFile(oldPath, newPath, file string) error {
if oldPath == newPath {
return errSamePath
}
oldFile := filepath.Join(oldPath, file)
newFile := filepath.Join(newPath, file)
if !fileExists(oldFile) {
return errNotExist
}
_ = os.MkdirAll(filepath.Dir(newFile), 0755)
return os.Rename(oldFile, newFile)
}
func migrateDir(oldPath, newPath string) error {
if oldPath == newPath {
return errSamePath
}
if !dirExists(oldPath) {
return errNotExist
}
_ = os.MkdirAll(filepath.Dir(newPath), 0755)
return os.Rename(oldPath, newPath)
}
func dirExists(path string) bool {
f, err := os.Stat(path)
return err == nil && f.IsDir()
}
func fileExists(path string) bool {
f, err := os.Stat(path)
return err == nil && !f.IsDir()
}
func ConfigFile() string {
return filepath.Join(ConfigDir(), "config.yml")
}
func HostsConfigFile() string {
return filepath.Join(ConfigDir(), "hosts.yml")
}
func ParseDefaultConfig() (Config, error) {
return parseConfig(ConfigFile())
}
func HomeDirPath(subdir string) (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
newPath := filepath.Join(homeDir, subdir)
return newPath, nil
}
var ReadConfigFile = func(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {
return nil, pathError(err)
}
defer f.Close()
data, err := ioutil.ReadAll(f)
if err != nil {
return nil, err
}
return data, nil
}
var WriteConfigFile = func(filename string, data []byte) error {
err := os.MkdirAll(filepath.Dir(filename), 0771)
if err != nil {
return pathError(err)
}
cfgFile, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) // cargo coded from setup
if err != nil {
return err
}
defer cfgFile.Close()
_, err = cfgFile.Write(data)
return err
}
var BackupConfigFile = func(filename string) error {
return os.Rename(filename, filename+".bak")
}
func parseConfigFile(filename string) ([]byte, *yaml.Node, error) {
data, err := ReadConfigFile(filename)
if err != nil {
return nil, nil, err
}
root, err := parseConfigData(data)
if err != nil {
return nil, nil, err
}
return data, root, err
}
func parseConfigData(data []byte) (*yaml.Node, error) {
var root yaml.Node
err := yaml.Unmarshal(data, &root)
if err != nil {
return nil, err
}
if len(root.Content) == 0 {
return &yaml.Node{
Kind: yaml.DocumentNode,
Content: []*yaml.Node{{Kind: yaml.MappingNode}},
}, nil
}
if root.Content[0].Kind != yaml.MappingNode {
return &root, fmt.Errorf("expected a top level map")
}
return &root, nil
}
func isLegacy(root *yaml.Node) bool {
for _, v := range root.Content[0].Content {
if v.Value == "github.com" {
return true
}
}
return false
}
func migrateConfig(filename string) error {
b, err := ReadConfigFile(filename)
if err != nil {
return err
}
var hosts map[string][]yaml.Node
err = yaml.Unmarshal(b, &hosts)
if err != nil {
return fmt.Errorf("error decoding legacy format: %w", err)
}
cfg := NewBlankConfig()
for hostname, entries := range hosts {
if len(entries) < 1 {
continue
}
mapContent := entries[0].Content
for i := 0; i < len(mapContent)-1; i += 2 {
if err := cfg.Set(hostname, mapContent[i].Value, mapContent[i+1].Value); err != nil {
return err
}
}
}
err = BackupConfigFile(filename)
if err != nil {
return fmt.Errorf("failed to back up existing config: %w", err)
}
return cfg.Write()
}
func parseConfig(filename string) (Config, error) {
_, root, err := parseConfigFile(filename)
if err != nil {
if os.IsNotExist(err) {
root = NewBlankRoot()
} else {
return nil, err
}
}
if isLegacy(root) {
err = migrateConfig(filename)
if err != nil {
return nil, fmt.Errorf("error migrating legacy config: %w", err)
}
_, root, err = parseConfigFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to reparse migrated config: %w", err)
}
} else {
if _, hostsRoot, err := parseConfigFile(HostsConfigFile()); err == nil {
if len(hostsRoot.Content[0].Content) > 0 {
newContent := []*yaml.Node{
{Value: "hosts"},
hostsRoot.Content[0],
}
restContent := root.Content[0].Content
root.Content[0].Content = append(newContent, restContent...)
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
}
return NewConfig(root), nil
}
func pathError(err error) error {
var pathError *os.PathError
if errors.As(err, &pathError) && errors.Is(pathError.Err, syscall.ENOTDIR) {
if p := findRegularFile(pathError.Path); p != "" {
return fmt.Errorf("remove or rename regular file `%s` (must be a directory)", p)
}
}
return err
}
func findRegularFile(p string) string {
for {
if s, err := os.Stat(p); err == nil && s.Mode().IsRegular() {
return p
}
newPath := filepath.Dir(p)
if newPath == p || newPath == "/" || newPath == "." {
break
}
p = newPath
}
return ""
}

View file

@ -1,551 +0,0 @@
package config
import (
"bytes"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
)
func Test_parseConfig(t *testing.T) {
defer stubConfig(`---
hosts:
github.com:
user: monalisa
oauth_token: OTOKEN
`, "")()
config, err := parseConfig("config.yml")
assert.NoError(t, err)
user, err := config.Get("github.com", "user")
assert.NoError(t, err)
assert.Equal(t, "monalisa", user)
token, err := config.Get("github.com", "oauth_token")
assert.NoError(t, err)
assert.Equal(t, "OTOKEN", token)
}
func Test_parseConfig_multipleHosts(t *testing.T) {
defer stubConfig(`---
hosts:
example.com:
user: wronguser
oauth_token: NOTTHIS
github.com:
user: monalisa
oauth_token: OTOKEN
`, "")()
config, err := parseConfig("config.yml")
assert.NoError(t, err)
user, err := config.Get("github.com", "user")
assert.NoError(t, err)
assert.Equal(t, "monalisa", user)
token, err := config.Get("github.com", "oauth_token")
assert.NoError(t, err)
assert.Equal(t, "OTOKEN", token)
}
func Test_parseConfig_hostsFile(t *testing.T) {
defer stubConfig("", `---
github.com:
user: monalisa
oauth_token: OTOKEN
`)()
config, err := parseConfig("config.yml")
assert.NoError(t, err)
user, err := config.Get("github.com", "user")
assert.NoError(t, err)
assert.Equal(t, "monalisa", user)
token, err := config.Get("github.com", "oauth_token")
assert.NoError(t, err)
assert.Equal(t, "OTOKEN", token)
}
func Test_parseConfig_hostFallback(t *testing.T) {
defer stubConfig(`---
git_protocol: ssh
`, `---
github.com:
user: monalisa
oauth_token: OTOKEN
example.com:
user: wronguser
oauth_token: NOTTHIS
git_protocol: https
`)()
config, err := parseConfig("config.yml")
assert.NoError(t, err)
val, err := config.Get("example.com", "git_protocol")
assert.NoError(t, err)
assert.Equal(t, "https", val)
val, err = config.Get("github.com", "git_protocol")
assert.NoError(t, err)
assert.Equal(t, "ssh", val)
val, err = config.Get("nonexistent.io", "git_protocol")
assert.NoError(t, err)
assert.Equal(t, "ssh", val)
}
func Test_parseConfig_migrateConfig(t *testing.T) {
defer stubConfig(`---
github.com:
- user: keiyuri
oauth_token: 123456
`, "")()
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer StubWriteConfig(&mainBuf, &hostsBuf)()
defer StubBackupConfig()()
_, err := parseConfig("config.yml")
assert.NoError(t, err)
expectedHosts := `github.com:
user: keiyuri
oauth_token: "123456"
`
assert.Equal(t, expectedHosts, hostsBuf.String())
assert.NotContains(t, mainBuf.String(), "github.com")
assert.NotContains(t, mainBuf.String(), "oauth_token")
}
func Test_parseConfigFile(t *testing.T) {
tests := []struct {
contents string
wantsErr bool
}{
{
contents: "",
wantsErr: true,
},
{
contents: " ",
wantsErr: false,
},
{
contents: "\n",
wantsErr: false,
},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("contents: %q", tt.contents), func(t *testing.T) {
defer stubConfig(tt.contents, "")()
_, yamlRoot, err := parseConfigFile("config.yml")
if tt.wantsErr != (err != nil) {
t.Fatalf("got error: %v", err)
}
if tt.wantsErr {
return
}
assert.Equal(t, yaml.MappingNode, yamlRoot.Content[0].Kind)
assert.Equal(t, 0, len(yamlRoot.Content[0].Content))
})
}
}
func Test_ConfigDir(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
onlyWindows bool
env map[string]string
output string
}{
{
name: "HOME/USERPROFILE specified",
env: map[string]string{
"GH_CONFIG_DIR": "",
"XDG_CONFIG_HOME": "",
"AppData": "",
"USERPROFILE": tempDir,
"HOME": tempDir,
},
output: filepath.Join(tempDir, ".config", "gh"),
},
{
name: "GH_CONFIG_DIR specified",
env: map[string]string{
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
},
output: filepath.Join(tempDir, "gh_config_dir"),
},
{
name: "XDG_CONFIG_HOME specified",
env: map[string]string{
"XDG_CONFIG_HOME": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
{
name: "GH_CONFIG_DIR and XDG_CONFIG_HOME specified",
env: map[string]string{
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
"XDG_CONFIG_HOME": tempDir,
},
output: filepath.Join(tempDir, "gh_config_dir"),
},
{
name: "AppData specified",
onlyWindows: true,
env: map[string]string{
"AppData": tempDir,
},
output: filepath.Join(tempDir, "GitHub CLI"),
},
{
name: "GH_CONFIG_DIR and AppData specified",
onlyWindows: true,
env: map[string]string{
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
"AppData": tempDir,
},
output: filepath.Join(tempDir, "gh_config_dir"),
},
{
name: "XDG_CONFIG_HOME and AppData specified",
onlyWindows: true,
env: map[string]string{
"XDG_CONFIG_HOME": tempDir,
"AppData": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
}
for _, tt := range tests {
if tt.onlyWindows && runtime.GOOS != "windows" {
continue
}
t.Run(tt.name, func(t *testing.T) {
if tt.env != nil {
for k, v := range tt.env {
old := os.Getenv(k)
os.Setenv(k, v)
defer os.Setenv(k, old)
}
}
// Create directory to skip auto migration code
// which gets run when target directory does not exist
_ = os.MkdirAll(tt.output, 0755)
assert.Equal(t, tt.output, ConfigDir())
})
}
}
func Test_configFile_Write_toDisk(t *testing.T) {
configDir := filepath.Join(t.TempDir(), ".config", "gh")
_ = os.MkdirAll(configDir, 0755)
os.Setenv(GH_CONFIG_DIR, configDir)
defer os.Unsetenv(GH_CONFIG_DIR)
cfg := NewFromString(`pager: less`)
err := cfg.Write()
if err != nil {
t.Fatal(err)
}
expectedConfig := "pager: less\n"
if configBytes, err := ioutil.ReadFile(filepath.Join(configDir, "config.yml")); err != nil {
t.Error(err)
} else if string(configBytes) != expectedConfig {
t.Errorf("expected config.yml %q, got %q", expectedConfig, string(configBytes))
}
if configBytes, err := ioutil.ReadFile(filepath.Join(configDir, "hosts.yml")); err != nil {
t.Error(err)
} else if string(configBytes) != "" {
t.Errorf("unexpected hosts.yml: %q", string(configBytes))
}
}
func Test_autoMigrateConfigDir_noMigration_notExist(t *testing.T) {
homeDir := t.TempDir()
migrateDir := t.TempDir()
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err := autoMigrateConfigDir(migrateDir)
assert.Equal(t, errNotExist, err)
files, err := ioutil.ReadDir(migrateDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
}
func Test_autoMigrateConfigDir_noMigration_samePath(t *testing.T) {
homeDir := t.TempDir()
migrateDir := filepath.Join(homeDir, ".config", "gh")
err := os.MkdirAll(migrateDir, 0755)
assert.NoError(t, err)
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err = autoMigrateConfigDir(migrateDir)
assert.Equal(t, errSamePath, err)
files, err := ioutil.ReadDir(migrateDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
}
func Test_autoMigrateConfigDir_migration(t *testing.T) {
homeDir := t.TempDir()
migrateDir := t.TempDir()
homeConfigDir := filepath.Join(homeDir, ".config", "gh")
migrateConfigDir := filepath.Join(migrateDir, ".config", "gh")
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err := os.MkdirAll(homeConfigDir, 0755)
assert.NoError(t, err)
f, err := ioutil.TempFile(homeConfigDir, "")
assert.NoError(t, err)
f.Close()
err = autoMigrateConfigDir(migrateConfigDir)
assert.NoError(t, err)
_, err = ioutil.ReadDir(homeConfigDir)
assert.True(t, os.IsNotExist(err))
files, err := ioutil.ReadDir(migrateConfigDir)
assert.NoError(t, err)
assert.Equal(t, 1, len(files))
}
func Test_StateDir(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
onlyWindows bool
env map[string]string
output string
}{
{
name: "HOME/USERPROFILE specified",
env: map[string]string{
"XDG_STATE_HOME": "",
"GH_CONFIG_DIR": "",
"XDG_CONFIG_HOME": "",
"LocalAppData": "",
"USERPROFILE": tempDir,
"HOME": tempDir,
},
output: filepath.Join(tempDir, ".local", "state", "gh"),
},
{
name: "XDG_STATE_HOME specified",
env: map[string]string{
"XDG_STATE_HOME": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
{
name: "LocalAppData specified",
onlyWindows: true,
env: map[string]string{
"LocalAppData": tempDir,
},
output: filepath.Join(tempDir, "GitHub CLI"),
},
{
name: "XDG_STATE_HOME and LocalAppData specified",
onlyWindows: true,
env: map[string]string{
"XDG_STATE_HOME": tempDir,
"LocalAppData": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
}
for _, tt := range tests {
if tt.onlyWindows && runtime.GOOS != "windows" {
continue
}
t.Run(tt.name, func(t *testing.T) {
if tt.env != nil {
for k, v := range tt.env {
old := os.Getenv(k)
os.Setenv(k, v)
defer os.Setenv(k, old)
}
}
// Create directory to skip auto migration code
// which gets run when target directory does not exist
_ = os.MkdirAll(tt.output, 0755)
assert.Equal(t, tt.output, StateDir())
})
}
}
func Test_autoMigrateStateDir_noMigration_notExist(t *testing.T) {
homeDir := t.TempDir()
migrateDir := t.TempDir()
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err := autoMigrateStateDir(migrateDir)
assert.Equal(t, errNotExist, err)
files, err := ioutil.ReadDir(migrateDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
}
func Test_autoMigrateStateDir_noMigration_samePath(t *testing.T) {
homeDir := t.TempDir()
migrateDir := filepath.Join(homeDir, ".config", "gh")
err := os.MkdirAll(migrateDir, 0755)
assert.NoError(t, err)
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err = autoMigrateStateDir(migrateDir)
assert.Equal(t, errSamePath, err)
files, err := ioutil.ReadDir(migrateDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
}
func Test_autoMigrateStateDir_migration(t *testing.T) {
homeDir := t.TempDir()
migrateDir := t.TempDir()
homeConfigDir := filepath.Join(homeDir, ".config", "gh")
migrateStateDir := filepath.Join(migrateDir, ".local", "state", "gh")
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err := os.MkdirAll(homeConfigDir, 0755)
assert.NoError(t, err)
err = ioutil.WriteFile(filepath.Join(homeConfigDir, "state.yml"), nil, 0755)
assert.NoError(t, err)
err = autoMigrateStateDir(migrateStateDir)
assert.NoError(t, err)
files, err := ioutil.ReadDir(homeConfigDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
files, err = ioutil.ReadDir(migrateStateDir)
assert.NoError(t, err)
assert.Equal(t, 1, len(files))
assert.Equal(t, "state.yml", files[0].Name())
}
func Test_DataDir(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
onlyWindows bool
env map[string]string
output string
}{
{
name: "HOME/USERPROFILE specified",
env: map[string]string{
"XDG_DATA_HOME": "",
"GH_CONFIG_DIR": "",
"XDG_CONFIG_HOME": "",
"LocalAppData": "",
"USERPROFILE": tempDir,
"HOME": tempDir,
},
output: filepath.Join(tempDir, ".local", "share", "gh"),
},
{
name: "XDG_DATA_HOME specified",
env: map[string]string{
"XDG_DATA_HOME": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
{
name: "LocalAppData specified",
onlyWindows: true,
env: map[string]string{
"LocalAppData": tempDir,
},
output: filepath.Join(tempDir, "GitHub CLI"),
},
{
name: "XDG_DATA_HOME and LocalAppData specified",
onlyWindows: true,
env: map[string]string{
"XDG_DATA_HOME": tempDir,
"LocalAppData": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
}
for _, tt := range tests {
if tt.onlyWindows && runtime.GOOS != "windows" {
continue
}
t.Run(tt.name, func(t *testing.T) {
if tt.env != nil {
for k, v := range tt.env {
old := os.Getenv(k)
os.Setenv(k, v)
defer os.Setenv(k, old)
}
}
assert.Equal(t, tt.output, DataDir())
})
}
}

View file

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

View file

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

View file

@ -0,0 +1,413 @@
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package config
import (
"sync"
)
// Ensure, that ConfigMock does implement Config.
// If this is not the case, regenerate this file with moq.
var _ Config = &ConfigMock{}
// ConfigMock is a mock implementation of Config.
//
// func TestSomethingThatUsesConfig(t *testing.T) {
//
// // make and configure a mocked Config
// mockedConfig := &ConfigMock{
// AliasesFunc: func() *AliasConfig {
// panic("mock out the Aliases method")
// },
// AuthTokenFunc: func(s string) (string, string) {
// panic("mock out the AuthToken method")
// },
// DefaultHostFunc: func() (string, string) {
// panic("mock out the DefaultHost method")
// },
// GetFunc: func(s1 string, s2 string) (string, error) {
// panic("mock out the Get method")
// },
// GetOrDefaultFunc: func(s1 string, s2 string) (string, error) {
// panic("mock out the GetOrDefault method")
// },
// HostsFunc: func() []string {
// panic("mock out the Hosts method")
// },
// SetFunc: func(s1 string, s2 string, s3 string) {
// panic("mock out the Set method")
// },
// UnsetHostFunc: func(s string) {
// panic("mock out the UnsetHost method")
// },
// WriteFunc: func() error {
// panic("mock out the Write method")
// },
// }
//
// // use mockedConfig in code that requires Config
// // and then make assertions.
//
// }
type ConfigMock struct {
// AliasesFunc mocks the Aliases method.
AliasesFunc func() *AliasConfig
// AuthTokenFunc mocks the AuthToken method.
AuthTokenFunc func(s string) (string, string)
// DefaultHostFunc mocks the DefaultHost method.
DefaultHostFunc func() (string, string)
// GetFunc mocks the Get method.
GetFunc func(s1 string, s2 string) (string, error)
// GetOrDefaultFunc mocks the GetOrDefault method.
GetOrDefaultFunc func(s1 string, s2 string) (string, error)
// HostsFunc mocks the Hosts method.
HostsFunc func() []string
// SetFunc mocks the Set method.
SetFunc func(s1 string, s2 string, s3 string)
// UnsetHostFunc mocks the UnsetHost method.
UnsetHostFunc func(s string)
// WriteFunc mocks the Write method.
WriteFunc func() error
// calls tracks calls to the methods.
calls struct {
// Aliases holds details about calls to the Aliases method.
Aliases []struct {
}
// AuthToken holds details about calls to the AuthToken method.
AuthToken []struct {
// S is the s argument value.
S string
}
// DefaultHost holds details about calls to the DefaultHost method.
DefaultHost []struct {
}
// Get holds details about calls to the Get method.
Get []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
}
// GetOrDefault holds details about calls to the GetOrDefault method.
GetOrDefault []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
}
// Hosts holds details about calls to the Hosts method.
Hosts []struct {
}
// Set holds details about calls to the Set method.
Set []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
// S3 is the s3 argument value.
S3 string
}
// UnsetHost holds details about calls to the UnsetHost method.
UnsetHost []struct {
// S is the s argument value.
S string
}
// Write holds details about calls to the Write method.
Write []struct {
}
}
lockAliases sync.RWMutex
lockAuthToken sync.RWMutex
lockDefaultHost sync.RWMutex
lockGet sync.RWMutex
lockGetOrDefault sync.RWMutex
lockHosts sync.RWMutex
lockSet sync.RWMutex
lockUnsetHost sync.RWMutex
lockWrite sync.RWMutex
}
// Aliases calls AliasesFunc.
func (mock *ConfigMock) Aliases() *AliasConfig {
if mock.AliasesFunc == nil {
panic("ConfigMock.AliasesFunc: method is nil but Config.Aliases was just called")
}
callInfo := struct {
}{}
mock.lockAliases.Lock()
mock.calls.Aliases = append(mock.calls.Aliases, callInfo)
mock.lockAliases.Unlock()
return mock.AliasesFunc()
}
// AliasesCalls gets all the calls that were made to Aliases.
// Check the length with:
// len(mockedConfig.AliasesCalls())
func (mock *ConfigMock) AliasesCalls() []struct {
} {
var calls []struct {
}
mock.lockAliases.RLock()
calls = mock.calls.Aliases
mock.lockAliases.RUnlock()
return calls
}
// AuthToken calls AuthTokenFunc.
func (mock *ConfigMock) AuthToken(s string) (string, string) {
if mock.AuthTokenFunc == nil {
panic("ConfigMock.AuthTokenFunc: method is nil but Config.AuthToken was just called")
}
callInfo := struct {
S string
}{
S: s,
}
mock.lockAuthToken.Lock()
mock.calls.AuthToken = append(mock.calls.AuthToken, callInfo)
mock.lockAuthToken.Unlock()
return mock.AuthTokenFunc(s)
}
// AuthTokenCalls gets all the calls that were made to AuthToken.
// Check the length with:
// len(mockedConfig.AuthTokenCalls())
func (mock *ConfigMock) AuthTokenCalls() []struct {
S string
} {
var calls []struct {
S string
}
mock.lockAuthToken.RLock()
calls = mock.calls.AuthToken
mock.lockAuthToken.RUnlock()
return calls
}
// DefaultHost calls DefaultHostFunc.
func (mock *ConfigMock) DefaultHost() (string, string) {
if mock.DefaultHostFunc == nil {
panic("ConfigMock.DefaultHostFunc: method is nil but Config.DefaultHost was just called")
}
callInfo := struct {
}{}
mock.lockDefaultHost.Lock()
mock.calls.DefaultHost = append(mock.calls.DefaultHost, callInfo)
mock.lockDefaultHost.Unlock()
return mock.DefaultHostFunc()
}
// DefaultHostCalls gets all the calls that were made to DefaultHost.
// Check the length with:
// len(mockedConfig.DefaultHostCalls())
func (mock *ConfigMock) DefaultHostCalls() []struct {
} {
var calls []struct {
}
mock.lockDefaultHost.RLock()
calls = mock.calls.DefaultHost
mock.lockDefaultHost.RUnlock()
return calls
}
// Get calls GetFunc.
func (mock *ConfigMock) Get(s1 string, s2 string) (string, error) {
if mock.GetFunc == nil {
panic("ConfigMock.GetFunc: method is nil but Config.Get was just called")
}
callInfo := struct {
S1 string
S2 string
}{
S1: s1,
S2: s2,
}
mock.lockGet.Lock()
mock.calls.Get = append(mock.calls.Get, callInfo)
mock.lockGet.Unlock()
return mock.GetFunc(s1, s2)
}
// GetCalls gets all the calls that were made to Get.
// Check the length with:
// len(mockedConfig.GetCalls())
func (mock *ConfigMock) GetCalls() []struct {
S1 string
S2 string
} {
var calls []struct {
S1 string
S2 string
}
mock.lockGet.RLock()
calls = mock.calls.Get
mock.lockGet.RUnlock()
return calls
}
// GetOrDefault calls GetOrDefaultFunc.
func (mock *ConfigMock) GetOrDefault(s1 string, s2 string) (string, error) {
if mock.GetOrDefaultFunc == nil {
panic("ConfigMock.GetOrDefaultFunc: method is nil but Config.GetOrDefault was just called")
}
callInfo := struct {
S1 string
S2 string
}{
S1: s1,
S2: s2,
}
mock.lockGetOrDefault.Lock()
mock.calls.GetOrDefault = append(mock.calls.GetOrDefault, callInfo)
mock.lockGetOrDefault.Unlock()
return mock.GetOrDefaultFunc(s1, s2)
}
// GetOrDefaultCalls gets all the calls that were made to GetOrDefault.
// Check the length with:
// len(mockedConfig.GetOrDefaultCalls())
func (mock *ConfigMock) GetOrDefaultCalls() []struct {
S1 string
S2 string
} {
var calls []struct {
S1 string
S2 string
}
mock.lockGetOrDefault.RLock()
calls = mock.calls.GetOrDefault
mock.lockGetOrDefault.RUnlock()
return calls
}
// Hosts calls HostsFunc.
func (mock *ConfigMock) Hosts() []string {
if mock.HostsFunc == nil {
panic("ConfigMock.HostsFunc: method is nil but Config.Hosts was just called")
}
callInfo := struct {
}{}
mock.lockHosts.Lock()
mock.calls.Hosts = append(mock.calls.Hosts, callInfo)
mock.lockHosts.Unlock()
return mock.HostsFunc()
}
// HostsCalls gets all the calls that were made to Hosts.
// Check the length with:
// len(mockedConfig.HostsCalls())
func (mock *ConfigMock) HostsCalls() []struct {
} {
var calls []struct {
}
mock.lockHosts.RLock()
calls = mock.calls.Hosts
mock.lockHosts.RUnlock()
return calls
}
// Set calls SetFunc.
func (mock *ConfigMock) Set(s1 string, s2 string, s3 string) {
if mock.SetFunc == nil {
panic("ConfigMock.SetFunc: method is nil but Config.Set was just called")
}
callInfo := struct {
S1 string
S2 string
S3 string
}{
S1: s1,
S2: s2,
S3: s3,
}
mock.lockSet.Lock()
mock.calls.Set = append(mock.calls.Set, callInfo)
mock.lockSet.Unlock()
mock.SetFunc(s1, s2, s3)
}
// SetCalls gets all the calls that were made to Set.
// Check the length with:
// len(mockedConfig.SetCalls())
func (mock *ConfigMock) SetCalls() []struct {
S1 string
S2 string
S3 string
} {
var calls []struct {
S1 string
S2 string
S3 string
}
mock.lockSet.RLock()
calls = mock.calls.Set
mock.lockSet.RUnlock()
return calls
}
// UnsetHost calls UnsetHostFunc.
func (mock *ConfigMock) UnsetHost(s string) {
if mock.UnsetHostFunc == nil {
panic("ConfigMock.UnsetHostFunc: method is nil but Config.UnsetHost was just called")
}
callInfo := struct {
S string
}{
S: s,
}
mock.lockUnsetHost.Lock()
mock.calls.UnsetHost = append(mock.calls.UnsetHost, callInfo)
mock.lockUnsetHost.Unlock()
mock.UnsetHostFunc(s)
}
// UnsetHostCalls gets all the calls that were made to UnsetHost.
// Check the length with:
// len(mockedConfig.UnsetHostCalls())
func (mock *ConfigMock) UnsetHostCalls() []struct {
S string
} {
var calls []struct {
S string
}
mock.lockUnsetHost.RLock()
calls = mock.calls.UnsetHost
mock.lockUnsetHost.RUnlock()
return calls
}
// Write calls WriteFunc.
func (mock *ConfigMock) Write() error {
if mock.WriteFunc == nil {
panic("ConfigMock.WriteFunc: method is nil but Config.Write was just called")
}
callInfo := struct {
}{}
mock.lockWrite.Lock()
mock.calls.Write = append(mock.calls.Write, callInfo)
mock.lockWrite.Unlock()
return mock.WriteFunc()
}
// WriteCalls gets all the calls that were made to Write.
// Check the length with:
// len(mockedConfig.WriteCalls())
func (mock *ConfigMock) WriteCalls() []struct {
} {
var calls []struct {
}
mock.lockWrite.RLock()
calls = mock.calls.Write
mock.lockWrite.RUnlock()
return calls
}

View file

@ -1,214 +0,0 @@
package config
import (
"fmt"
"gopkg.in/yaml.v3"
)
// This interface describes interacting with some persistent configuration for gh.
type Config interface {
Get(string, string) (string, error)
GetWithSource(string, string) (string, string, error)
Set(string, string, string) error
UnsetHost(string)
Hosts() ([]string, error)
DefaultHost() (string, error)
DefaultHostWithSource() (string, string, error)
Aliases() (*AliasConfig, error)
CheckWriteable(string, string) error
Write() error
}
type ConfigOption struct {
Key string
Description string
DefaultValue string
AllowedValues []string
}
var configOptions = []ConfigOption{
{
Key: "git_protocol",
Description: "the protocol to use for git clone and push operations",
DefaultValue: "https",
AllowedValues: []string{"https", "ssh"},
},
{
Key: "editor",
Description: "the text editor program to use for authoring text",
DefaultValue: "",
},
{
Key: "prompt",
Description: "toggle interactive prompting in the terminal",
DefaultValue: "enabled",
AllowedValues: []string{"enabled", "disabled"},
},
{
Key: "pager",
Description: "the terminal pager program to send standard output to",
DefaultValue: "",
},
{
Key: "http_unix_socket",
Description: "the path to a unix socket through which to make HTTP connection",
DefaultValue: "",
},
{
Key: "browser",
Description: "the web browser to use for opening URLs",
DefaultValue: "",
},
}
func ConfigOptions() []ConfigOption {
return configOptions
}
func ValidateKey(key string) error {
for _, configKey := range configOptions {
if key == configKey.Key {
return nil
}
}
return fmt.Errorf("invalid key")
}
type InvalidValueError struct {
ValidValues []string
}
func (e InvalidValueError) Error() string {
return "invalid value"
}
func ValidateValue(key, value string) error {
var validValues []string
for _, v := range configOptions {
if v.Key == key {
validValues = v.AllowedValues
break
}
}
if validValues == nil {
return nil
}
for _, v := range validValues {
if v == value {
return nil
}
}
return &InvalidValueError{ValidValues: validValues}
}
func NewConfig(root *yaml.Node) Config {
return &fileConfig{
ConfigMap: ConfigMap{Root: root.Content[0]},
documentRoot: root,
}
}
// NewFromString initializes a Config from a yaml string
func NewFromString(str string) Config {
root, err := parseConfigData([]byte(str))
if err != nil {
panic(err)
}
return NewConfig(root)
}
// NewBlankConfig initializes a config file pre-populated with comments and default values
func NewBlankConfig() Config {
return NewConfig(NewBlankRoot())
}
func NewBlankRoot() *yaml.Node {
return &yaml.Node{
Kind: yaml.DocumentNode,
Content: []*yaml.Node{
{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{
HeadComment: "What protocol to use when performing git operations. Supported values: ssh, https",
Kind: yaml.ScalarNode,
Value: "git_protocol",
},
{
Kind: yaml.ScalarNode,
Value: "https",
},
{
HeadComment: "What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.",
Kind: yaml.ScalarNode,
Value: "editor",
},
{
Kind: yaml.ScalarNode,
Value: "",
},
{
HeadComment: "When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled",
Kind: yaml.ScalarNode,
Value: "prompt",
},
{
Kind: yaml.ScalarNode,
Value: "enabled",
},
{
HeadComment: "A pager program to send command output to, e.g. \"less\". Set the value to \"cat\" to disable the pager.",
Kind: yaml.ScalarNode,
Value: "pager",
},
{
Kind: yaml.ScalarNode,
Value: "",
},
{
HeadComment: "Aliases allow you to create nicknames for gh commands",
Kind: yaml.ScalarNode,
Value: "aliases",
},
{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{
Kind: yaml.ScalarNode,
Value: "co",
},
{
Kind: yaml.ScalarNode,
Value: "pr checkout",
},
},
},
{
HeadComment: "The path to a unix socket through which send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.",
Kind: yaml.ScalarNode,
Value: "http_unix_socket",
},
{
Kind: yaml.ScalarNode,
Value: "",
},
{
HeadComment: "What web browser gh should use when opening URLs. If blank, will refer to environment.",
Kind: yaml.ScalarNode,
Value: "browser",
},
{
Kind: yaml.ScalarNode,
Value: "",
},
},
},
},
}
}

View file

@ -1,118 +0,0 @@
package config
import (
"bytes"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/stretchr/testify/assert"
)
func Test_fileConfig_Set(t *testing.T) {
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer StubWriteConfig(&mainBuf, &hostsBuf)()
c := NewBlankConfig()
assert.NoError(t, c.Set("", "editor", "nano"))
assert.NoError(t, c.Set("github.com", "git_protocol", "ssh"))
assert.NoError(t, c.Set("example.com", "editor", "vim"))
assert.NoError(t, c.Set("github.com", "user", "hubot"))
assert.NoError(t, c.Write())
assert.Contains(t, mainBuf.String(), "editor: nano")
assert.Contains(t, mainBuf.String(), "git_protocol: https")
assert.Equal(t, `github.com:
git_protocol: ssh
user: hubot
example.com:
editor: vim
`, hostsBuf.String())
}
func Test_defaultConfig(t *testing.T) {
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer StubWriteConfig(&mainBuf, &hostsBuf)()
cfg := NewBlankConfig()
assert.NoError(t, cfg.Write())
expected := heredoc.Doc(`
# What protocol to use when performing git operations. Supported values: ssh, https
git_protocol: https
# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.
editor:
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
prompt: enabled
# A pager program to send command output to, e.g. "less". Set the value to "cat" to disable the pager.
pager:
# Aliases allow you to create nicknames for gh commands
aliases:
co: pr checkout
# The path to a unix socket through which send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.
http_unix_socket:
# What web browser gh should use when opening URLs. If blank, will refer to environment.
browser:
`)
assert.Equal(t, expected, mainBuf.String())
assert.Equal(t, "", hostsBuf.String())
proto, err := cfg.Get("", "git_protocol")
assert.NoError(t, err)
assert.Equal(t, "https", proto)
editor, err := cfg.Get("", "editor")
assert.NoError(t, err)
assert.Equal(t, "", editor)
aliases, err := cfg.Aliases()
assert.NoError(t, err)
assert.Equal(t, len(aliases.All()), 1)
expansion, _ := aliases.Get("co")
assert.Equal(t, expansion, "pr checkout")
browser, err := cfg.Get("", "browser")
assert.NoError(t, err)
assert.Equal(t, "", browser)
}
func Test_ValidateValue(t *testing.T) {
err := ValidateValue("git_protocol", "sshpps")
assert.EqualError(t, err, "invalid value")
err = ValidateValue("git_protocol", "ssh")
assert.NoError(t, err)
err = ValidateValue("editor", "vim")
assert.NoError(t, err)
err = ValidateValue("got", "123")
assert.NoError(t, err)
err = ValidateValue("http_unix_socket", "really_anything/is/allowed/and/net.Dial\\(...\\)/will/ultimately/validate")
assert.NoError(t, err)
}
func Test_ValidateKey(t *testing.T) {
err := ValidateKey("invalid")
assert.EqualError(t, err, "invalid key")
err = ValidateKey("git_protocol")
assert.NoError(t, err)
err = ValidateKey("editor")
assert.NoError(t, err)
err = ValidateKey("prompt")
assert.NoError(t, err)
err = ValidateKey("pager")
assert.NoError(t, err)
err = ValidateKey("http_unix_socket")
assert.NoError(t, err)
err = ValidateKey("browser")
assert.NoError(t, err)
}

View file

@ -1,126 +0,0 @@
package config
import (
"fmt"
"os"
"strconv"
"github.com/cli/cli/v2/internal/ghinstance"
)
const (
GH_HOST = "GH_HOST"
GH_TOKEN = "GH_TOKEN"
GITHUB_TOKEN = "GITHUB_TOKEN"
GH_ENTERPRISE_TOKEN = "GH_ENTERPRISE_TOKEN"
GITHUB_ENTERPRISE_TOKEN = "GITHUB_ENTERPRISE_TOKEN"
CODESPACES = "CODESPACES"
)
type ReadOnlyEnvError struct {
Variable string
}
func (e *ReadOnlyEnvError) Error() string {
return fmt.Sprintf("read-only value in %s", e.Variable)
}
func InheritEnv(c Config) Config {
return &envConfig{Config: c}
}
type envConfig struct {
Config
}
func (c *envConfig) Hosts() ([]string, error) {
hasDefault := false
hosts, err := c.Config.Hosts()
for _, h := range hosts {
if h == ghinstance.Default() {
hasDefault = true
}
}
token, _ := AuthTokenFromEnv(ghinstance.Default())
if (err != nil || !hasDefault) && token != "" {
hosts = append([]string{ghinstance.Default()}, hosts...)
return hosts, nil
}
return hosts, err
}
func (c *envConfig) DefaultHost() (string, error) {
val, _, err := c.DefaultHostWithSource()
return val, err
}
func (c *envConfig) DefaultHostWithSource() (string, string, error) {
if host := os.Getenv(GH_HOST); host != "" {
return host, GH_HOST, nil
}
return c.Config.DefaultHostWithSource()
}
func (c *envConfig) Get(hostname, key string) (string, error) {
val, _, err := c.GetWithSource(hostname, key)
return val, err
}
func (c *envConfig) GetWithSource(hostname, key string) (string, string, error) {
if hostname != "" && key == "oauth_token" {
if token, env := AuthTokenFromEnv(hostname); token != "" {
return token, env, nil
}
}
return c.Config.GetWithSource(hostname, key)
}
func (c *envConfig) CheckWriteable(hostname, key string) error {
if hostname != "" && key == "oauth_token" {
if token, env := AuthTokenFromEnv(hostname); token != "" {
return &ReadOnlyEnvError{Variable: env}
}
}
return c.Config.CheckWriteable(hostname, key)
}
func AuthTokenFromEnv(hostname string) (string, string) {
if ghinstance.IsEnterprise(hostname) {
if token := os.Getenv(GH_ENTERPRISE_TOKEN); token != "" {
return token, GH_ENTERPRISE_TOKEN
}
if token := os.Getenv(GITHUB_ENTERPRISE_TOKEN); token != "" {
return token, GITHUB_ENTERPRISE_TOKEN
}
if isCodespaces, _ := strconv.ParseBool(os.Getenv(CODESPACES)); isCodespaces {
return os.Getenv(GITHUB_TOKEN), GITHUB_TOKEN
}
return "", ""
}
if token := os.Getenv(GH_TOKEN); token != "" {
return token, GH_TOKEN
}
return os.Getenv(GITHUB_TOKEN), GITHUB_TOKEN
}
func AuthTokenProvidedFromEnv() bool {
return os.Getenv(GH_ENTERPRISE_TOKEN) != "" ||
os.Getenv(GITHUB_ENTERPRISE_TOKEN) != "" ||
os.Getenv(GH_TOKEN) != "" ||
os.Getenv(GITHUB_TOKEN) != ""
}
func IsHostEnv(src string) bool {
return src == GH_HOST
}
func IsEnterpriseEnv(src string) bool {
return src == GH_ENTERPRISE_TOKEN || src == GITHUB_ENTERPRISE_TOKEN
}

View file

@ -1,374 +0,0 @@
package config
import (
"os"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/stretchr/testify/assert"
)
func setenv(t *testing.T, key, newValue string) {
oldValue, hasValue := os.LookupEnv(key)
os.Setenv(key, newValue)
t.Cleanup(func() {
if hasValue {
os.Setenv(key, oldValue)
} else {
os.Unsetenv(key)
}
})
}
func TestInheritEnv(t *testing.T) {
orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN")
orig_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN")
orig_GH_TOKEN := os.Getenv("GH_TOKEN")
orig_GH_ENTERPRISE_TOKEN := os.Getenv("GH_ENTERPRISE_TOKEN")
orig_AppData := os.Getenv("AppData")
t.Cleanup(func() {
os.Setenv("GITHUB_TOKEN", orig_GITHUB_TOKEN)
os.Setenv("GITHUB_ENTERPRISE_TOKEN", orig_GITHUB_ENTERPRISE_TOKEN)
os.Setenv("GH_TOKEN", orig_GH_TOKEN)
os.Setenv("GH_ENTERPRISE_TOKEN", orig_GH_ENTERPRISE_TOKEN)
os.Setenv("AppData", orig_AppData)
})
type wants struct {
hosts []string
token string
source string
writeable bool
}
tests := []struct {
name string
baseConfig string
GITHUB_TOKEN string
GITHUB_ENTERPRISE_TOKEN string
GH_TOKEN string
GH_ENTERPRISE_TOKEN string
CODESPACES string
hostname string
wants wants
}{
{
name: "blank",
baseConfig: ``,
hostname: "github.com",
wants: wants{
hosts: []string{},
token: "",
source: ".config.gh.config.yml",
writeable: true,
},
},
{
name: "GITHUB_TOKEN over blank config",
baseConfig: ``,
GITHUB_TOKEN: "OTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "OTOKEN",
source: "GITHUB_TOKEN",
writeable: false,
},
},
{
name: "GH_TOKEN over blank config",
baseConfig: ``,
GH_TOKEN: "OTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "OTOKEN",
source: "GH_TOKEN",
writeable: false,
},
},
{
name: "GITHUB_TOKEN not applicable to GHE",
baseConfig: ``,
GITHUB_TOKEN: "OTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{"github.com"},
token: "",
source: ".config.gh.config.yml",
writeable: true,
},
},
{
name: "GH_TOKEN not applicable to GHE",
baseConfig: ``,
GH_TOKEN: "OTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{"github.com"},
token: "",
source: ".config.gh.config.yml",
writeable: true,
},
},
{
name: "GITHUB_TOKEN allowed in Codespaces",
baseConfig: ``,
GITHUB_TOKEN: "OTOKEN",
hostname: "example.org",
CODESPACES: "true",
wants: wants{
hosts: []string{"github.com"},
token: "OTOKEN",
source: "GITHUB_TOKEN",
writeable: false,
},
},
{
name: "GITHUB_ENTERPRISE_TOKEN over blank config",
baseConfig: ``,
GITHUB_ENTERPRISE_TOKEN: "ENTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{},
token: "ENTOKEN",
source: "GITHUB_ENTERPRISE_TOKEN",
writeable: false,
},
},
{
name: "GH_ENTERPRISE_TOKEN over blank config",
baseConfig: ``,
GH_ENTERPRISE_TOKEN: "ENTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{},
token: "ENTOKEN",
source: "GH_ENTERPRISE_TOKEN",
writeable: false,
},
},
{
name: "token from file",
baseConfig: heredoc.Doc(`
hosts:
github.com:
oauth_token: OTOKEN
`),
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "OTOKEN",
source: ".config.gh.hosts.yml",
writeable: true,
},
},
{
name: "GITHUB_TOKEN shadows token from file",
baseConfig: heredoc.Doc(`
hosts:
github.com:
oauth_token: OTOKEN
`),
GITHUB_TOKEN: "ENVTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "ENVTOKEN",
source: "GITHUB_TOKEN",
writeable: false,
},
},
{
name: "GH_TOKEN shadows token from file",
baseConfig: heredoc.Doc(`
hosts:
github.com:
oauth_token: OTOKEN
`),
GH_TOKEN: "ENVTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "ENVTOKEN",
source: "GH_TOKEN",
writeable: false,
},
},
{
name: "GITHUB_ENTERPRISE_TOKEN shadows token from file",
baseConfig: heredoc.Doc(`
hosts:
example.org:
oauth_token: OTOKEN
`),
GITHUB_ENTERPRISE_TOKEN: "ENVTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{"example.org"},
token: "ENVTOKEN",
source: "GITHUB_ENTERPRISE_TOKEN",
writeable: false,
},
},
{
name: "GH_ENTERPRISE_TOKEN shadows token from file",
baseConfig: heredoc.Doc(`
hosts:
example.org:
oauth_token: OTOKEN
`),
GH_ENTERPRISE_TOKEN: "ENVTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{"example.org"},
token: "ENVTOKEN",
source: "GH_ENTERPRISE_TOKEN",
writeable: false,
},
},
{
name: "GH_TOKEN shadows token from GITHUB_TOKEN",
baseConfig: ``,
GH_TOKEN: "GHTOKEN",
GITHUB_TOKEN: "GITHUBTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "GHTOKEN",
source: "GH_TOKEN",
writeable: false,
},
},
{
name: "GH_ENTERPRISE_TOKEN shadows token from GITHUB_ENTERPRISE_TOKEN",
baseConfig: ``,
GH_ENTERPRISE_TOKEN: "GHTOKEN",
GITHUB_ENTERPRISE_TOKEN: "GITHUBTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{},
token: "GHTOKEN",
source: "GH_ENTERPRISE_TOKEN",
writeable: false,
},
},
{
name: "GITHUB_TOKEN adds host entry",
baseConfig: heredoc.Doc(`
hosts:
example.org:
oauth_token: OTOKEN
`),
GITHUB_TOKEN: "ENVTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com", "example.org"},
token: "ENVTOKEN",
source: "GITHUB_TOKEN",
writeable: false,
},
},
{
name: "GH_TOKEN adds host entry",
baseConfig: heredoc.Doc(`
hosts:
example.org:
oauth_token: OTOKEN
`),
GH_TOKEN: "ENVTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com", "example.org"},
token: "ENVTOKEN",
source: "GH_TOKEN",
writeable: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setenv(t, "GITHUB_TOKEN", tt.GITHUB_TOKEN)
setenv(t, "GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN)
setenv(t, "GH_TOKEN", tt.GH_TOKEN)
setenv(t, "GH_ENTERPRISE_TOKEN", tt.GH_ENTERPRISE_TOKEN)
setenv(t, "AppData", "")
setenv(t, "CODESPACES", tt.CODESPACES)
baseCfg := NewFromString(tt.baseConfig)
cfg := InheritEnv(baseCfg)
hosts, _ := cfg.Hosts()
assert.Equal(t, tt.wants.hosts, hosts)
val, source, _ := cfg.GetWithSource(tt.hostname, "oauth_token")
assert.Equal(t, tt.wants.token, val)
assert.Regexp(t, tt.wants.source, source)
val, _ = cfg.Get(tt.hostname, "oauth_token")
assert.Equal(t, tt.wants.token, val)
err := cfg.CheckWriteable(tt.hostname, "oauth_token")
if tt.wants.writeable != (err == nil) {
t.Errorf("CheckWriteable() = %v, wants %v", err, tt.wants.writeable)
}
})
}
}
func TestAuthTokenProvidedFromEnv(t *testing.T) {
orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN")
orig_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN")
orig_GH_TOKEN := os.Getenv("GH_TOKEN")
orig_GH_ENTERPRISE_TOKEN := os.Getenv("GH_ENTERPRISE_TOKEN")
t.Cleanup(func() {
os.Setenv("GITHUB_TOKEN", orig_GITHUB_TOKEN)
os.Setenv("GITHUB_ENTERPRISE_TOKEN", orig_GITHUB_ENTERPRISE_TOKEN)
os.Setenv("GH_TOKEN", orig_GH_TOKEN)
os.Setenv("GH_ENTERPRISE_TOKEN", orig_GH_ENTERPRISE_TOKEN)
})
tests := []struct {
name string
GITHUB_TOKEN string
GITHUB_ENTERPRISE_TOKEN string
GH_TOKEN string
GH_ENTERPRISE_TOKEN string
provided bool
}{
{
name: "no env tokens",
provided: false,
},
{
name: "GH_TOKEN",
GH_TOKEN: "TOKEN",
provided: true,
},
{
name: "GITHUB_TOKEN",
GITHUB_TOKEN: "TOKEN",
provided: true,
},
{
name: "GH_ENTERPRISE_TOKEN",
GH_ENTERPRISE_TOKEN: "TOKEN",
provided: true,
},
{
name: "GITHUB_ENTERPRISE_TOKEN",
GITHUB_ENTERPRISE_TOKEN: "TOKEN",
provided: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
os.Setenv("GITHUB_TOKEN", tt.GITHUB_TOKEN)
os.Setenv("GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN)
os.Setenv("GH_TOKEN", tt.GH_TOKEN)
os.Setenv("GH_ENTERPRISE_TOKEN", tt.GH_ENTERPRISE_TOKEN)
assert.Equal(t, tt.provided, AuthTokenProvidedFromEnv())
})
}
}

View file

@ -1,318 +0,0 @@
package config
import (
"bytes"
"errors"
"fmt"
"sort"
"strings"
"github.com/cli/cli/v2/internal/ghinstance"
"gopkg.in/yaml.v3"
)
// This type implements a Config interface and represents a config file on disk.
type fileConfig struct {
ConfigMap
documentRoot *yaml.Node
}
type HostConfig struct {
ConfigMap
Host string
}
func (c *fileConfig) Root() *yaml.Node {
return c.ConfigMap.Root
}
func (c *fileConfig) Get(hostname, key string) (string, error) {
val, _, err := c.GetWithSource(hostname, key)
return val, err
}
func (c *fileConfig) GetWithSource(hostname, key string) (string, string, error) {
if hostname != "" {
var notFound *NotFoundError
hostCfg, err := c.configForHost(hostname)
if err != nil && !errors.As(err, &notFound) {
return "", "", err
}
var hostValue string
if hostCfg != nil {
hostValue, err = hostCfg.GetStringValue(key)
if err != nil && !errors.As(err, &notFound) {
return "", "", err
}
}
if hostValue != "" {
return hostValue, HostsConfigFile(), nil
}
}
defaultSource := ConfigFile()
value, err := c.GetStringValue(key)
var notFound *NotFoundError
if err != nil && errors.As(err, &notFound) {
return defaultFor(key), defaultSource, nil
} else if err != nil {
return "", defaultSource, err
}
if value == "" {
return defaultFor(key), defaultSource, nil
}
return value, defaultSource, nil
}
func (c *fileConfig) Set(hostname, key, value string) error {
if hostname == "" {
return c.SetStringValue(key, value)
} else {
hostCfg, err := c.configForHost(hostname)
var notFound *NotFoundError
if errors.As(err, &notFound) {
hostCfg = c.makeConfigForHost(hostname)
} else if err != nil {
return err
}
return hostCfg.SetStringValue(key, value)
}
}
func (c *fileConfig) UnsetHost(hostname string) {
if hostname == "" {
return
}
hostsEntry, err := c.FindEntry("hosts")
if err != nil {
return
}
cm := ConfigMap{hostsEntry.ValueNode}
cm.RemoveEntry(hostname)
}
func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) {
hosts, err := c.hostEntries()
if err != nil {
return nil, err
}
for _, hc := range hosts {
if strings.EqualFold(hc.Host, hostname) {
return hc, nil
}
}
return nil, &NotFoundError{fmt.Errorf("could not find config entry for %q", hostname)}
}
func (c *fileConfig) CheckWriteable(hostname, key string) error {
// TODO: check filesystem permissions
return nil
}
func (c *fileConfig) Write() error {
mainData := yaml.Node{Kind: yaml.MappingNode}
hostsData := yaml.Node{Kind: yaml.MappingNode}
nodes := c.documentRoot.Content[0].Content
for i := 0; i < len(nodes)-1; i += 2 {
if nodes[i].Value == "hosts" {
hostsData.Content = append(hostsData.Content, nodes[i+1].Content...)
} else {
mainData.Content = append(mainData.Content, nodes[i], nodes[i+1])
}
}
mainBytes, err := yaml.Marshal(&mainData)
if err != nil {
return err
}
filename := ConfigFile()
err = WriteConfigFile(filename, yamlNormalize(mainBytes))
if err != nil {
return err
}
hostsBytes, err := yaml.Marshal(&hostsData)
if err != nil {
return err
}
return WriteConfigFile(HostsConfigFile(), yamlNormalize(hostsBytes))
}
func (c *fileConfig) Aliases() (*AliasConfig, error) {
// The complexity here is for dealing with either a missing or empty aliases key. It's something
// we'll likely want for other config sections at some point.
entry, err := c.FindEntry("aliases")
var nfe *NotFoundError
notFound := errors.As(err, &nfe)
if err != nil && !notFound {
return nil, err
}
toInsert := []*yaml.Node{}
keyNode := entry.KeyNode
valueNode := entry.ValueNode
if keyNode == nil {
keyNode = &yaml.Node{
Kind: yaml.ScalarNode,
Value: "aliases",
}
toInsert = append(toInsert, keyNode)
}
if valueNode == nil || valueNode.Kind != yaml.MappingNode {
valueNode = &yaml.Node{
Kind: yaml.MappingNode,
Value: "",
}
toInsert = append(toInsert, valueNode)
}
if len(toInsert) > 0 {
newContent := []*yaml.Node{}
if notFound {
newContent = append(c.Root().Content, keyNode, valueNode)
} else {
for i := 0; i < len(c.Root().Content); i++ {
if i == entry.Index {
newContent = append(newContent, keyNode, valueNode)
i++
} else {
newContent = append(newContent, c.Root().Content[i])
}
}
}
c.Root().Content = newContent
}
return &AliasConfig{
Parent: c,
ConfigMap: ConfigMap{Root: valueNode},
}, nil
}
func (c *fileConfig) hostEntries() ([]*HostConfig, error) {
entry, err := c.FindEntry("hosts")
if err != nil {
return []*HostConfig{}, nil
}
hostConfigs, err := c.parseHosts(entry.ValueNode)
if err != nil {
return nil, fmt.Errorf("could not parse hosts config: %w", err)
}
return hostConfigs, nil
}
// Hosts returns a list of all known hostnames configured in hosts.yml
func (c *fileConfig) Hosts() ([]string, error) {
entries, err := c.hostEntries()
if err != nil {
return nil, err
}
hostnames := []string{}
for _, entry := range entries {
hostnames = append(hostnames, entry.Host)
}
sort.SliceStable(hostnames, func(i, j int) bool { return hostnames[i] == ghinstance.Default() })
return hostnames, nil
}
func (c *fileConfig) DefaultHost() (string, error) {
val, _, err := c.DefaultHostWithSource()
return val, err
}
func (c *fileConfig) DefaultHostWithSource() (string, string, error) {
hosts, err := c.Hosts()
if err == nil && len(hosts) == 1 {
return hosts[0], HostsConfigFile(), nil
}
return ghinstance.Default(), "", nil
}
func (c *fileConfig) makeConfigForHost(hostname string) *HostConfig {
hostRoot := &yaml.Node{Kind: yaml.MappingNode}
hostCfg := &HostConfig{
Host: hostname,
ConfigMap: ConfigMap{Root: hostRoot},
}
var notFound *NotFoundError
hostsEntry, err := c.FindEntry("hosts")
if errors.As(err, &notFound) {
hostsEntry.KeyNode = &yaml.Node{
Kind: yaml.ScalarNode,
Value: "hosts",
}
hostsEntry.ValueNode = &yaml.Node{Kind: yaml.MappingNode}
root := c.Root()
root.Content = append(root.Content, hostsEntry.KeyNode, hostsEntry.ValueNode)
} else if err != nil {
panic(err)
}
hostsEntry.ValueNode.Content = append(hostsEntry.ValueNode.Content,
&yaml.Node{
Kind: yaml.ScalarNode,
Value: hostname,
}, hostRoot)
return hostCfg
}
func (c *fileConfig) parseHosts(hostsEntry *yaml.Node) ([]*HostConfig, error) {
hostConfigs := []*HostConfig{}
for i := 0; i < len(hostsEntry.Content)-1; i = i + 2 {
hostname := hostsEntry.Content[i].Value
hostRoot := hostsEntry.Content[i+1]
hostConfig := HostConfig{
ConfigMap: ConfigMap{Root: hostRoot},
Host: hostname,
}
hostConfigs = append(hostConfigs, &hostConfig)
}
if len(hostConfigs) == 0 {
return nil, errors.New("could not find any host configurations")
}
return hostConfigs, nil
}
func yamlNormalize(b []byte) []byte {
if bytes.Equal(b, []byte("{}\n")) {
return []byte{}
}
return b
}
func defaultFor(key string) string {
for _, co := range configOptions {
if co.Key == key {
return co.DefaultValue
}
}
return ""
}

View file

@ -1,15 +0,0 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_fileConfig_Hosts(t *testing.T) {
c := NewBlankConfig()
hosts, err := c.Hosts()
require.NoError(t, err)
assert.Equal(t, []string{}, hosts)
}

View file

@ -1,59 +1,105 @@
package config
import (
"errors"
"io"
"os"
"path/filepath"
"testing"
ghConfig "github.com/cli/go-gh/pkg/config"
)
type ConfigStub map[string]string
func NewBlankConfig() *ConfigMock {
defaultStr := `
# What protocol to use when performing git operations. Supported values: ssh, https
git_protocol: https
# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.
editor:
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
prompt: enabled
# A pager program to send command output to, e.g. "less". Set the value to "cat" to disable the pager.
pager:
# Aliases allow you to create nicknames for gh commands
aliases:
co: pr checkout
# The path to a unix socket through which send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.
http_unix_socket:
# What web browser gh should use when opening URLs. If blank, will refer to environment.
browser:
`
return NewFromString(defaultStr)
}
func genKey(host, key string) string {
if host != "" {
return host + ":" + key
func NewFromString(cfgStr string) *ConfigMock {
c := ghConfig.ReadFromString(cfgStr)
cfg := cfg{c}
mock := &ConfigMock{}
mock.AuthTokenFunc = func(host string) (string, string) {
token, _ := c.Get([]string{"hosts", host, "oauth_token"})
return token, "oauth_token"
}
return key
}
func (c ConfigStub) Get(host, key string) (string, error) {
val, _, err := c.GetWithSource(host, key)
return val, err
}
func (c ConfigStub) GetWithSource(host, key string) (string, string, error) {
if v, found := c[genKey(host, key)]; found {
return v, "(memory)", nil
mock.GetFunc = func(host, key string) (string, error) {
return cfg.Get(host, key)
}
return "", "", errors.New("not found")
mock.GetOrDefaultFunc = func(host, key string) (string, error) {
return cfg.GetOrDefault(host, key)
}
mock.SetFunc = func(host, key, value string) {
cfg.Set(host, key, value)
}
mock.UnsetHostFunc = func(host string) {
cfg.UnsetHost(host)
}
mock.HostsFunc = func() []string {
keys, _ := c.Keys([]string{"hosts"})
return keys
}
mock.DefaultHostFunc = func() (string, string) {
return "github.com", "default"
}
mock.AliasesFunc = func() *AliasConfig {
return &AliasConfig{cfg: c}
}
mock.WriteFunc = func() error {
return cfg.Write()
}
return mock
}
func (c ConfigStub) Set(host, key, value string) error {
c[genKey(host, key)] = value
return nil
}
// StubWriteConfig stubs out the filesystem where config file are written.
// It then returns a function that will read in the config files into io.Writers.
// It automatically cleans up environment variables and written files.
func StubWriteConfig(t *testing.T) func(io.Writer, io.Writer) {
t.Helper()
tempDir := t.TempDir()
t.Setenv("GH_CONFIG_DIR", tempDir)
return func(wc io.Writer, wh io.Writer) {
config, err := os.Open(filepath.Join(tempDir, "config.yml"))
if err != nil {
return
}
defer config.Close()
configData, err := io.ReadAll(config)
if err != nil {
return
}
_, err = wc.Write(configData)
if err != nil {
return
}
func (c ConfigStub) Aliases() (*AliasConfig, error) {
return nil, nil
}
func (c ConfigStub) Hosts() ([]string, error) {
return nil, nil
}
func (c ConfigStub) UnsetHost(hostname string) {
}
func (c ConfigStub) CheckWriteable(host, key string) error {
return nil
}
func (c ConfigStub) Write() error {
c["_written"] = "true"
return nil
}
func (c ConfigStub) DefaultHost() (string, error) {
return "", nil
}
func (c ConfigStub) DefaultHostWithSource() (string, string, error) {
return "", "", nil
hosts, err := os.Open(filepath.Join(tempDir, "hosts.yml"))
if err != nil {
return
}
defer hosts.Close()
hostsData, err := io.ReadAll(hosts)
if err != nil {
return
}
_, err = wh.Write(hostsData)
if err != nil {
return
}
}
}

View file

@ -1,64 +0,0 @@
package config
import (
"fmt"
"io"
"os"
"path/filepath"
)
func StubBackupConfig() func() {
orig := BackupConfigFile
BackupConfigFile = func(_ string) error {
return nil
}
return func() {
BackupConfigFile = orig
}
}
func StubWriteConfig(wc io.Writer, wh io.Writer) func() {
orig := WriteConfigFile
WriteConfigFile = func(fn string, data []byte) error {
switch filepath.Base(fn) {
case "config.yml":
_, err := wc.Write(data)
return err
case "hosts.yml":
_, err := wh.Write(data)
return err
default:
return fmt.Errorf("write to unstubbed file: %q", fn)
}
}
return func() {
WriteConfigFile = orig
}
}
func stubConfig(main, hosts string) func() {
orig := ReadConfigFile
ReadConfigFile = func(fn string) ([]byte, error) {
switch filepath.Base(fn) {
case "config.yml":
if main == "" {
return []byte(nil), os.ErrNotExist
} else {
return []byte(main), nil
}
case "hosts.yml":
if hosts == "" {
return []byte(nil), os.ErrNotExist
} else {
return []byte(hosts), nil
}
default:
return []byte(nil), fmt.Errorf("read from unstubbed file: %q", fn)
}
}
return func() {
ReadConfigFile = orig
}
}

View file

@ -4,7 +4,6 @@ import (
"bufio"
"bytes"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
@ -116,7 +115,7 @@ func TestManPrintFlagsHidesShortDeprecated(t *testing.T) {
func TestGenManTree(t *testing.T) {
c := &cobra.Command{Use: "do [OPTIONS] arg1 arg2"}
tmpdir, err := ioutil.TempDir("", "test-gen-man-tree")
tmpdir, err := os.MkdirTemp("", "test-gen-man-tree")
if err != nil {
t.Fatalf("Failed to create tmpdir: %s", err.Error())
}
@ -147,7 +146,7 @@ func assertLineFound(scanner *bufio.Scanner, expectedLine string) error {
}
func BenchmarkGenManToFile(b *testing.B) {
file, err := ioutil.TempFile(b.TempDir(), "")
file, err := os.CreateTemp(b.TempDir(), "")
if err != nil {
b.Fatal(err)
}

View file

@ -85,6 +85,7 @@ func GenMarkdown(cmd *cobra.Command, w io.Writer) error {
// GenMarkdownCustom creates custom markdown output.
func GenMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string) string) error {
fmt.Fprint(w, "{% raw %}")
fmt.Fprintf(w, "## %s\n\n", cmd.CommandPath())
hasLong := cmd.Long != ""
@ -112,6 +113,7 @@ func GenMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string)
if err := printOptions(w, cmd); err != nil {
return err
}
fmt.Fprint(w, "{% endraw %}\n")
if len(cmd.Example) > 0 {
fmt.Fprint(w, "### Examples\n\n{% highlight bash %}{% raw %}\n")
@ -190,7 +192,7 @@ func GenMarkdownTree(cmd *cobra.Command, dir string) error {
return GenMarkdownTreeCustom(cmd, dir, emptyStr, identity)
}
// GenMarkdownTreeCustom is the the same as GenMarkdownTree, but
// GenMarkdownTreeCustom is the same as GenMarkdownTree, but
// with custom filePrepender and linkHandler.
func GenMarkdownTreeCustom(cmd *cobra.Command, dir string, filePrepender, linkHandler func(string) string) error {
for _, c := range cmd.Commands() {

View file

@ -2,7 +2,6 @@ package docs
import (
"bytes"
"io/ioutil"
"os"
"path/filepath"
"testing"
@ -67,7 +66,7 @@ func TestGenMdNoHiddenParents(t *testing.T) {
func TestGenMdTree(t *testing.T) {
c := &cobra.Command{Use: "do [OPTIONS] arg1 arg2"}
tmpdir, err := ioutil.TempDir("", "test-gen-md-tree")
tmpdir, err := os.MkdirTemp("", "test-gen-md-tree")
if err != nil {
t.Fatalf("Failed to create tmpdir: %v", err)
}
@ -83,7 +82,7 @@ func TestGenMdTree(t *testing.T) {
}
func BenchmarkGenMarkdownToFile(b *testing.B) {
file, err := ioutil.TempFile(b.TempDir(), "")
file, err := os.CreateTemp(b.TempDir(), "")
if err != nil {
b.Fatal(err)
}

View file

@ -0,0 +1,29 @@
package featuredetection
type DisabledDetectorMock struct{}
func (md *DisabledDetectorMock) IssueFeatures() (IssueFeatures, error) {
return IssueFeatures{}, nil
}
func (md *DisabledDetectorMock) PullRequestFeatures() (PullRequestFeatures, error) {
return PullRequestFeatures{}, nil
}
func (md *DisabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error) {
return RepositoryFeatures{}, nil
}
type EnabledDetectorMock struct{}
func (md *EnabledDetectorMock) IssueFeatures() (IssueFeatures, error) {
return allIssueFeatures, nil
}
func (md *EnabledDetectorMock) PullRequestFeatures() (PullRequestFeatures, error) {
return allPullRequestFeatures, nil
}
func (md *EnabledDetectorMock) RepositoryFeatures() (RepositoryFeatures, error) {
return allRepositoryFeatures, nil
}

View file

@ -0,0 +1,173 @@
package featuredetection
import (
"net/http"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
)
type Detector interface {
IssueFeatures() (IssueFeatures, error)
PullRequestFeatures() (PullRequestFeatures, error)
RepositoryFeatures() (RepositoryFeatures, error)
}
type IssueFeatures struct {
StateReason bool
}
var allIssueFeatures = IssueFeatures{
StateReason: true,
}
type PullRequestFeatures struct {
ReviewDecision bool
StatusCheckRollup bool
BranchProtectionRule bool
MergeQueue bool
}
var allPullRequestFeatures = PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: true,
}
type RepositoryFeatures struct {
IssueTemplateMutation bool
IssueTemplateQuery bool
PullRequestTemplateQuery bool
VisibilityField bool
AutoMerge bool
}
var allRepositoryFeatures = RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
PullRequestTemplateQuery: true,
VisibilityField: true,
AutoMerge: true,
}
type detector struct {
host string
httpClient *http.Client
}
func NewDetector(httpClient *http.Client, host string) Detector {
return &detector{
httpClient: httpClient,
host: host,
}
}
func (d *detector) IssueFeatures() (IssueFeatures, error) {
if !ghinstance.IsEnterprise(d.host) {
return allIssueFeatures, nil
}
features := IssueFeatures{
StateReason: false,
}
var featureDetection struct {
Issue struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"Issue: __type(name: \"Issue\")"`
}
gql := api.NewClientFromHTTP(d.httpClient)
err := gql.Query(d.host, "Issue_fields", &featureDetection, nil)
if err != nil {
return features, err
}
for _, field := range featureDetection.Issue.Fields {
if field.Name == "stateReason" {
features.StateReason = true
}
}
return features, nil
}
func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) {
// TODO: reinstate the short-circuit once the APIs are fully available on github.com
// https://github.com/cli/cli/issues/5778
//
// if !ghinstance.IsEnterprise(d.host) {
// return allPullRequestFeatures, nil
// }
features := PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
}
var featureDetection struct {
PullRequest struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"PullRequest: __type(name: \"PullRequest\")"`
}
gql := api.NewClientFromHTTP(d.httpClient)
err := gql.Query(d.host, "PullRequest_fields", &featureDetection, nil)
if err != nil {
return features, err
}
for _, field := range featureDetection.PullRequest.Fields {
if field.Name == "isInMergeQueue" {
features.MergeQueue = true
}
}
return features, nil
}
func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) {
if !ghinstance.IsEnterprise(d.host) {
return allRepositoryFeatures, nil
}
features := RepositoryFeatures{
IssueTemplateQuery: true,
IssueTemplateMutation: true,
}
var featureDetection struct {
Repository struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"Repository: __type(name: \"Repository\")"`
}
gql := api.NewClientFromHTTP(d.httpClient)
err := gql.Query(d.host, "Repository_fields", &featureDetection, nil)
if err != nil {
return features, err
}
for _, field := range featureDetection.Repository.Fields {
if field.Name == "pullRequestTemplates" {
features.PullRequestTemplateQuery = true
}
if field.Name == "visibility" {
features.VisibilityField = true
}
if field.Name == "autoMergeAllowed" {
features.AutoMerge = true
}
}
return features, nil
}

View file

@ -0,0 +1,264 @@
package featuredetection
import (
"net/http"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
)
func TestIssueFeatures(t *testing.T) {
tests := []struct {
name string
hostname string
queryResponse map[string]string
wantFeatures IssueFeatures
wantErr bool
}{
{
name: "github.com",
hostname: "github.com",
wantFeatures: IssueFeatures{
StateReason: true,
},
wantErr: false,
},
{
name: "GHE empty response",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Issue_fields\b`: `{"data": {}}`,
},
wantFeatures: IssueFeatures{
StateReason: false,
},
wantErr: false,
},
{
name: "GHE has state reason field",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Issue_fields\b`: heredoc.Doc(`
{ "data": { "Issue": { "fields": [
{"name": "stateReason"}
] } } }
`),
},
wantFeatures: IssueFeatures{
StateReason: true,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
for query, resp := range tt.queryResponse {
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
}
detector := detector{host: tt.hostname, httpClient: httpClient}
gotFeatures, err := detector.IssueFeatures()
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantFeatures, gotFeatures)
})
}
}
func TestPullRequestFeatures(t *testing.T) {
tests := []struct {
name string
hostname string
queryResponse map[string]string
wantFeatures PullRequestFeatures
wantErr bool
}{
{
name: "github.com",
hostname: "github.com",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{ "data": { "PullRequest": { "fields": [
{"name": "isInMergeQueue"},
{"name": "isMergeQueueEnabled"}
] } } }
`),
},
wantFeatures: PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: true,
},
wantErr: false,
},
{
name: "github.com with no merge queue",
hostname: "github.com",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{ "data": { "PullRequest": { "fields": [
] } } }
`),
},
wantFeatures: PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: false,
},
wantErr: false,
},
{
name: "GHE",
hostname: "git.my.org",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{ "data": { "PullRequest": { "fields": [
{"name": "isInMergeQueue"},
{"name": "isMergeQueueEnabled"}
] } } }
`),
},
wantFeatures: PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: true,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
for query, resp := range tt.queryResponse {
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
}
detector := detector{host: tt.hostname, httpClient: httpClient}
gotFeatures, err := detector.PullRequestFeatures()
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantFeatures, gotFeatures)
})
}
}
func TestRepositoryFeatures(t *testing.T) {
tests := []struct {
name string
hostname string
queryResponse map[string]string
wantFeatures RepositoryFeatures
wantErr bool
}{
{
name: "github.com",
hostname: "github.com",
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
PullRequestTemplateQuery: true,
VisibilityField: true,
AutoMerge: true,
},
wantErr: false,
},
{
name: "GHE empty response",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Repository_fields\b`: `{"data": {}}`,
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
PullRequestTemplateQuery: false,
},
wantErr: false,
},
{
name: "GHE has pull request template query",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Repository_fields\b`: heredoc.Doc(`
{ "data": { "Repository": { "fields": [
{"name": "pullRequestTemplates"}
] } } }
`),
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
PullRequestTemplateQuery: true,
},
wantErr: false,
},
{
name: "GHE has visibility field",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Repository_fields\b`: heredoc.Doc(`
{ "data": { "Repository": { "fields": [
{"name": "visibility"}
] } } }
`),
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
VisibilityField: true,
},
wantErr: false,
},
{
name: "GHE has automerge field",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Repository_fields\b`: heredoc.Doc(`
{ "data": { "Repository": { "fields": [
{"name": "autoMergeAllowed"}
] } } }
`),
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
AutoMerge: true,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
for query, resp := range tt.queryResponse {
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
}
detector := detector{host: tt.hostname, httpClient: httpClient}
gotFeatures, err := detector.RepositoryFeatures()
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantFeatures, gotFeatures)
})
}
}

View file

@ -22,6 +22,10 @@ func IsEnterprise(h string) bool {
return normalizedHostName != defaultHostname && normalizedHostName != localhost
}
func isGarage(h string) bool {
return strings.EqualFold(h, "garage.github.com")
}
// NormalizeHostname returns the canonical host name of a GitHub instance
func NormalizeHostname(h string) string {
hostname := strings.ToLower(h)
@ -36,12 +40,7 @@ func NormalizeHostname(h string) string {
return hostname
}
func HostnameValidator(v interface{}) error {
hostname, valid := v.(string)
if !valid {
return errors.New("hostname is not a string")
}
func HostnameValidator(hostname string) error {
if len(strings.TrimSpace(hostname)) < 1 {
return errors.New("a value is required")
}
@ -52,6 +51,9 @@ func HostnameValidator(v interface{}) error {
}
func GraphQLEndpoint(hostname string) string {
if isGarage(hostname) {
return fmt.Sprintf("https://%s/api/graphql", hostname)
}
if IsEnterprise(hostname) {
return fmt.Sprintf("https://%s/api/graphql", hostname)
}
@ -62,6 +64,9 @@ func GraphQLEndpoint(hostname string) string {
}
func RESTPrefix(hostname string) string {
if isGarage(hostname) {
return fmt.Sprintf("https://%s/api/v3/", hostname)
}
if IsEnterprise(hostname) {
return fmt.Sprintf("https://%s/api/v3/", hostname)
}
@ -82,6 +87,9 @@ func GistPrefix(hostname string) string {
}
func GistHost(hostname string) string {
if isGarage(hostname) {
return fmt.Sprintf("%s/gist/", hostname)
}
if IsEnterprise(hostname) {
return fmt.Sprintf("%s/gist/", hostname)
}

View file

@ -27,6 +27,10 @@ func TestIsEnterprise(t *testing.T) {
host: "api.github.localhost",
want: false,
},
{
host: "garage.github.com",
want: false,
},
{
host: "ghe.io",
want: true,
@ -74,6 +78,10 @@ func TestNormalizeHostname(t *testing.T) {
host: "api.github.localhost",
want: "github.localhost",
},
{
host: "garage.github.com",
want: "github.com",
},
{
host: "GHE.IO",
want: "ghe.io",
@ -95,7 +103,7 @@ func TestNormalizeHostname(t *testing.T) {
func TestHostnameValidator(t *testing.T) {
tests := []struct {
name string
input interface{}
input string
wantsErr bool
}{
{
@ -118,11 +126,6 @@ func TestHostnameValidator(t *testing.T) {
input: "internal.instance:2205",
wantsErr: true,
},
{
name: "non-string hostname",
input: 62,
wantsErr: true,
},
}
for _, tt := range tests {
@ -149,6 +152,10 @@ func TestGraphQLEndpoint(t *testing.T) {
host: "github.localhost",
want: "http://api.github.localhost/graphql",
},
{
host: "garage.github.com",
want: "https://garage.github.com/api/graphql",
},
{
host: "ghe.io",
want: "https://ghe.io/api/graphql",
@ -176,6 +183,10 @@ func TestRESTPrefix(t *testing.T) {
host: "github.localhost",
want: "http://api.github.localhost/",
},
{
host: "garage.github.com",
want: "https://garage.github.com/api/v3/",
},
{
host: "ghe.io",
want: "https://ghe.io/api/v3/",

View file

@ -5,8 +5,9 @@ import (
"net/url"
"strings"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghinstance"
ghAuth "github.com/cli/go-gh/pkg/auth"
"github.com/cli/go-gh/pkg/repository"
)
// Interface describes an object that represents a GitHub repository
@ -35,19 +36,9 @@ func FullName(r Interface) string {
return fmt.Sprintf("%s/%s", r.RepoOwner(), r.RepoName())
}
var defaultHostOverride string
func defaultHost() string {
if defaultHostOverride != "" {
return defaultHostOverride
}
return ghinstance.Default()
}
// SetDefaultHost overrides the default GitHub hostname for FromFullName.
// TODO: remove after FromFullName approach is revisited
func SetDefaultHost(host string) {
defaultHostOverride = host
host, _ := ghAuth.DefaultHost()
return host
}
// FromFullName extracts the GitHub repository information from the following
@ -59,28 +50,11 @@ func FromFullName(nwo string) (Interface, error) {
// FromFullNameWithHost is like FromFullName that defaults to a specific host for values that don't
// explicitly include a hostname.
func FromFullNameWithHost(nwo, fallbackHost string) (Interface, error) {
if git.IsURL(nwo) {
u, err := git.ParseURL(nwo)
if err != nil {
return nil, err
}
return FromURL(u)
}
parts := strings.SplitN(nwo, "/", 4)
for _, p := range parts {
if len(p) == 0 {
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo)
}
}
switch len(parts) {
case 3:
return NewWithHost(parts[1], parts[2], parts[0]), nil
case 2:
return NewWithHost(parts[0], parts[1], fallbackHost), nil
default:
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo)
repo, err := repository.ParseWithHost(nwo, fallbackHost)
if err != nil {
return nil, err
}
return NewWithHost(repo.Owner(), repo.Name(), repo.Host()), nil
}
// FromURL extracts the GitHub repository information from a git remote URL

View file

@ -194,7 +194,7 @@ func TestFromFullName(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.hostOverride != "" {
SetDefaultHost(tt.hostOverride)
t.Setenv("GH_HOST", tt.hostOverride)
}
r, err := FromFullName(tt.input)
if tt.wantErr != nil {

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