Merge remote-tracking branch 'origin/trunk' into add-more-author-infomation
This commit is contained in:
commit
84a15d0943
549 changed files with 42643 additions and 16709 deletions
24
.devcontainer/devcontainer.json
Normal file
24
.devcontainer/devcontainer.json
Normal 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
13
.github/SECURITY.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
11
.github/workflows/codeql.yml
vendored
11
.github/workflows/codeql.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
23
.github/workflows/go.yml
vendored
23
.github/workflows/go.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
13
.github/workflows/issueauto.yml
vendored
13
.github/workflows/issueauto.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
21
.github/workflows/lint.yml
vendored
21
.github/workflows/lint.yml
vendored
|
|
@ -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/
|
||||
|
|
|
|||
6
.github/workflows/prauto.yml
vendored
6
.github/workflows/prauto.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
86
.github/workflows/releases.yml
vendored
86
.github/workflows/releases.yml
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: GitHub’s official command line tool.
|
||||
|
|
|
|||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"search.exclude": {
|
||||
"vendor/**": true
|
||||
}
|
||||
}
|
||||
20
README.md
20
README.md
|
|
@ -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).
|
||||
|
|
|
|||
179
api/cache.go
179
api/cache.go
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
534
api/client.go
534
api/client.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
131
api/http_client.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
219
api/queries_branch_issue_reference.go
Normal file
219
api/queries_branch_issue_reference.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
38
build/windows/gh.wixproj
Normal 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
77
build/windows/gh.wxs
Normal 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
54
build/windows/ui.wxs
Normal 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>
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
143
cmd/gh/main.go
143
cmd/gh/main.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
592
git/client.go
Normal 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
1170
git/client_test.go
Normal file
File diff suppressed because it is too large
Load diff
100
git/command.go
Normal file
100
git/command.go
Normal 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
39
git/errors.go
Normal 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
|
||||
}
|
||||
432
git/git.go
432
git/git.go
|
|
@ -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/")
|
||||
}
|
||||
220
git/git_test.go
220
git/git_test.go
|
|
@ -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
76
git/objects.go
Normal 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
|
||||
}
|
||||
169
git/remote.go
169
git/remote.go
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
})
|
||||
}
|
||||
|
|
@ -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
81
go.mod
|
|
@ -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
505
go.sum
|
|
@ -13,20 +13,6 @@ cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKV
|
|||
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
|
||||
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
|
||||
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
|
||||
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
|
||||
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
|
||||
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
|
||||
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
|
||||
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
|
||||
cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
|
||||
cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
|
||||
cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
|
||||
cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
|
||||
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
|
||||
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
|
||||
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
|
||||
cloud.google.com/go v0.98.0/go.mod h1:ua6Ush4NALrHk5QXDWnjvZHN93OuF0HfuEPq9I1X0cM=
|
||||
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
|
||||
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
|
||||
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
|
||||
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
|
||||
|
|
@ -35,7 +21,6 @@ cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4g
|
|||
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
|
||||
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
|
||||
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
|
||||
cloud.google.com/go/firestore v1.6.1/go.mod h1:asNXNOzBdyVQmEU+ggO8UPodTkEVFW5Qx+rwHnAz+EY=
|
||||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
|
||||
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
|
||||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
16
internal/browser/browser.go
Normal file
16
internal/browser/browser.go
Normal 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
40
internal/browser/stub.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¶ms)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
|||
145
internal/codespaces/grpc/client.go
Normal file
145
internal/codespaces/grpc/client.go
Normal 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
|
||||
}
|
||||
84
internal/codespaces/grpc/client_test.go
Normal file
84
internal/codespaces/grpc/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
16
internal/codespaces/grpc/generate.md
Normal file
16
internal/codespaces/grpc/generate.md
Normal 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)
|
||||
26
internal/codespaces/grpc/generate.sh
Executable file
26
internal/codespaces/grpc/generate.sh
Executable 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!'
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
}
|
||||
34
internal/codespaces/grpc/test/channel.go
Normal file
34
internal/codespaces/grpc/test/channel.go
Normal 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
|
||||
}
|
||||
62
internal/codespaces/grpc/test/server.go
Normal file
62
internal/codespaces/grpc/test/server.go
Normal 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
|
||||
}
|
||||
}
|
||||
31
internal/codespaces/grpc/test/session.go
Normal file
31
internal/codespaces/grpc/test/session.go
Normal 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
|
||||
}
|
||||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
224
internal/config/config.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ¬Found) {
|
||||
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
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
413
internal/config/config_mock.go
Normal file
413
internal/config/config_mock.go
Normal 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
|
||||
}
|
||||
|
|
@ -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: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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, ¬Found) {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var hostValue string
|
||||
if hostCfg != nil {
|
||||
hostValue, err = hostCfg.GetStringValue(key)
|
||||
if err != nil && !errors.As(err, ¬Found) {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
if hostValue != "" {
|
||||
return hostValue, HostsConfigFile(), nil
|
||||
}
|
||||
}
|
||||
|
||||
defaultSource := ConfigFile()
|
||||
|
||||
value, err := c.GetStringValue(key)
|
||||
|
||||
var notFound *NotFoundError
|
||||
|
||||
if err != nil && errors.As(err, ¬Found) {
|
||||
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, ¬Found) {
|
||||
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, ¬Found) {
|
||||
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 ""
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
29
internal/featuredetection/detector_mock.go
Normal file
29
internal/featuredetection/detector_mock.go
Normal 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
|
||||
}
|
||||
173
internal/featuredetection/feature_detection.go
Normal file
173
internal/featuredetection/feature_detection.go
Normal 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
|
||||
}
|
||||
264
internal/featuredetection/feature_detection_test.go
Normal file
264
internal/featuredetection/feature_detection_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue