Merge remote-tracking branch 'origin/trunk' into fix/error-on-no-browser

This commit is contained in:
nate smith 2023-07-10 15:29:17 -07:00
commit a889bfab20
272 changed files with 23509 additions and 3667 deletions

View file

346
.github/workflows/deployment.yml vendored Normal file
View file

@ -0,0 +1,346 @@
name: Deployment
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}
cancel-in-progress: true
permissions:
contents: write
on:
workflow_dispatch:
inputs:
tag_name:
required: true
type: string
environment:
default: production
type: environment
go_version:
default: "1.19"
type: string
platforms:
default: "linux,macos,windows"
type: string
release:
description: "Whether to create a GitHub Release"
type: boolean
default: true
jobs:
linux:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
if: contains(inputs.platforms, 'linux')
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: ${{ inputs.go_version }}
- name: Install GoReleaser
uses: goreleaser/goreleaser-action@v4
with:
version: "~1.17.1"
install-only: true
- name: Build release binaries
env:
TAG_NAME: ${{ inputs.tag_name }}
run: script/release --local "$TAG_NAME" --platform linux
- name: Generate web manual pages
run: |
go run ./cmd/gen-docs --website --doc-path dist/manual
tar -czvf dist/manual.tar.gz -C dist -- manual
- uses: actions/upload-artifact@v3
with:
name: linux
if-no-files-found: error
retention-days: 7
path: |
dist/*.tar.gz
dist/*.rpm
dist/*.deb
macos:
runs-on: macos-latest
environment: ${{ inputs.environment }}
if: contains(inputs.platforms, 'macos')
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: ${{ inputs.go_version }}
- name: Configure macOS signing
if: inputs.environment == 'production'
env:
APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }}
APPLE_APPLICATION_CERT: ${{ secrets.APPLE_APPLICATION_CERT }}
APPLE_APPLICATION_CERT_PASSWORD: ${{ secrets.APPLE_APPLICATION_CERT_PASSWORD }}
run: |
keychain="$RUNNER_TEMP/buildagent.keychain"
keychain_password="password1"
security create-keychain -p "$keychain_password" "$keychain"
security default-keychain -s "$keychain"
security unlock-keychain -p "$keychain_password" "$keychain"
base64 -D <<<"$APPLE_APPLICATION_CERT" > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$APPLE_APPLICATION_CERT_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S "apple-tool:,apple:,codesign:" -s -k "$keychain_password" "$keychain"
rm "$RUNNER_TEMP/cert.p12"
- name: Install GoReleaser
uses: goreleaser/goreleaser-action@v4
with:
version: "~1.17.1"
install-only: true
- name: Build release binaries
env:
TAG_NAME: ${{ inputs.tag_name }}
APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }}
run: script/release --local "$TAG_NAME" --platform macos
- name: Notarize macOS archives
if: inputs.environment == 'production'
env:
APPLE_ID: ${{ vars.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_DEVELOPER_ID: ${{ vars.APPLE_DEVELOPER_ID }}
run: |
shopt -s failglob
script/sign dist/gh_*_macOS_*.zip
- uses: actions/upload-artifact@v3
with:
name: macos
if-no-files-found: error
retention-days: 7
path: |
dist/*.tar.gz
dist/*.zip
windows:
runs-on: windows-latest
environment: ${{ inputs.environment }}
if: contains(inputs.platforms, 'windows')
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: ${{ inputs.go_version }}
- name: Obtain signing certificate
id: obtain_cert
if: inputs.environment == 'production'
shell: bash
run: |
base64 -d <<<"$CERT_CONTENTS" > ./cert.pfx
printf "cert-file=%s\n" ".\\cert.pfx" >> $GITHUB_OUTPUT
env:
CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }}
- name: Install GoReleaser
uses: goreleaser/goreleaser-action@v4
with:
version: "~1.17.1"
install-only: true
- name: Build release binaries
shell: bash
env:
TAG_NAME: ${{ inputs.tag_name }}
CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }}
CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
run: script/release --local "$TAG_NAME" --platform windows
- name: Set up MSBuild
id: setupmsbuild
uses: microsoft/setup-msbuild@v1.3.1
- name: Build MSI
shell: bash
env:
MSBUILD_PATH: ${{ steps.setupmsbuild.outputs.msbuildPath }}
run: |
for ZIP_FILE in dist/gh_*_windows_*.zip; do
MSI_NAME="$(basename "$ZIP_FILE" ".zip")"
MSI_VERSION="$(cut -d_ -f2 <<<"$MSI_NAME" | cut -d- -f1)"
case "$MSI_NAME" in
*_386 )
source_dir="$PWD/dist/windows_windows_386"
platform="x86"
;;
*_amd64 )
source_dir="$PWD/dist/windows_windows_amd64_v1"
platform="x64"
;;
*_arm64 )
echo "skipping building MSI for arm64 because WiX 3.11 doesn't support it: https://github.com/wixtoolset/issues/issues/6141" >&2
continue
#source_dir="$PWD/dist/windows_windows_arm64"
#platform="arm64"
;;
* )
printf "unsupported architecture: %s\n" "$MSI_NAME" >&2
exit 1
;;
esac
"${MSBUILD_PATH}\MSBuild.exe" ./build/windows/gh.wixproj -p:SourceDir="$source_dir" -p:OutputPath="$PWD/dist" -p:OutputName="$MSI_NAME" -p:ProductVersion="${MSI_VERSION#v}" -p:Platform="$platform"
done
- name: Sign MSI
if: inputs.environment == 'production'
shell: pwsh
run: |
Get-ChildItem -Path .\dist -Filter *.msi | ForEach-Object {
.\script\sign $_.FullName
}
env:
CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }}
CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
- uses: actions/upload-artifact@v3
with:
name: windows
if-no-files-found: error
retention-days: 7
path: |
dist/*.zip
dist/*.msi
release:
runs-on: ubuntu-latest
needs: [linux, macos, windows]
if: inputs.release
steps:
- name: Checkout cli/cli
uses: actions/checkout@v3
- name: Merge built artifacts
uses: actions/download-artifact@v3
- name: Checkout documentation site
uses: actions/checkout@v3
with:
repository: github/cli.github.com
path: site
fetch-depth: 0
token: ${{ secrets.SITE_DEPLOY_PAT }}
- name: Update site man pages
env:
GIT_COMMITTER_NAME: cli automation
GIT_AUTHOR_NAME: cli automation
GIT_COMMITTER_EMAIL: noreply@github.com
GIT_AUTHOR_EMAIL: noreply@github.com
TAG_NAME: ${{ inputs.tag_name }}
run: |
git -C site rm 'manual/gh*.md' 2>/dev/null || true
tar -xzvf linux/manual.tar.gz -C site
git -C site add 'manual/gh*.md'
sed -i.bak -E "s/(assign version = )\".+\"/\1\"${TAG_NAME#v}\"/" site/index.html
rm -f site/index.html.bak
git -C site add index.html
git -C site diff --quiet --cached || git -C site commit -m "gh ${TAG_NAME#v}"
- name: Prepare release assets
env:
TAG_NAME: ${{ inputs.tag_name }}
run: |
shopt -s failglob
rm -rf dist
mkdir dist
mv -v {linux,macos,windows}/gh_* dist/
- name: Install packaging dependencies
run: sudo apt-get install -y rpm reprepro
- name: Set up GPG
if: inputs.environment == 'production'
env:
GPG_PUBKEY: ${{ secrets.GPG_PUBKEY }}
GPG_KEY: ${{ secrets.GPG_KEY }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
GPG_KEYGRIP: ${{ secrets.GPG_KEYGRIP }}
run: |
base64 -d <<<"$GPG_PUBKEY" | gpg --import --no-tty --batch --yes
base64 -d <<<"$GPG_KEY" | gpg --import --no-tty --batch --yes
echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf
gpg-connect-agent RELOADAGENT /bye
/usr/lib/gnupg2/gpg-preset-passphrase --preset "$GPG_KEYGRIP" <<<"$GPG_PASSPHRASE"
- name: Sign RPMs
if: inputs.environment == 'production'
run: |
cp script/rpmmacros ~/.rpmmacros
rpmsign --addsign dist/*.rpm
- name: Run createrepo
env:
GPG_SIGN: ${{ inputs.environment == 'production' }}
run: |
mkdir -p site/packages/rpm
cp dist/*.rpm site/packages/rpm/
./script/createrepo.sh
cp -r dist/repodata site/packages/rpm/
pushd site/packages/rpm
[ "$GPG_SIGN" = "false" ] || gpg --yes --detach-sign --armor repodata/repomd.xml
popd
- name: Run reprepro
env:
GPG_SIGN: ${{ inputs.environment == 'production' }}
# 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
[ "$GPG_SIGN" = "true" ] || sed -i.bak '/^SignWith:/d' script/distributions
for release in $RELEASES; do
for file in dist/*.deb; do
reprepro --confdir="+b/script" includedeb "$release" "$file"
done
done
cp -a dists/ pool/ upload/
mkdir -p site/packages
cp -a upload/* site/packages/
- name: Create the release
env:
# In non-production environments, the assets will not have been signed
DO_PUBLISH: ${{ inputs.environment == 'production' }}
TAG_NAME: ${{ inputs.tag_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
shopt -s failglob
pushd dist
shasum -a 256 gh_* > checksums.txt
mv checksums.txt gh_${TAG_NAME#v}_checksums.txt
popd
release_args=(
"$TAG_NAME"
--title "GitHub CLI ${TAG_NAME#v}"
--target "$GITHUB_SHA"
--generate-notes
)
if [[ $TAG_NAME == *-* ]]; then
release_args+=( --prerelease )
else
release_args+=( --discussion-category "General" )
fi
guard="echo"
[ "$DO_PUBLISH" = "false" ] || guard=""
script/label-assets dist/gh_* | xargs $guard gh release create "${release_args[@]}" --
- name: Publish site
env:
DO_PUBLISH: ${{ inputs.environment == 'production' && !contains(inputs.tag_name, '-') }}
TAG_NAME: ${{ inputs.tag_name }}
GIT_COMMITTER_NAME: cli automation
GIT_AUTHOR_NAME: cli automation
GIT_COMMITTER_EMAIL: noreply@github.com
GIT_AUTHOR_EMAIL: noreply@github.com
working-directory: ./site
run: |
git add packages
git commit -m "Add rpm and deb packages for $TAG_NAME"
if [ "$DO_PUBLISH" = "true" ]; then
git push
else
git log --oneline @{upstream}..
git diff --name-status @{upstream}..
fi
- name: Bump homebrew-core formula
uses: mislav/bump-homebrew-formula-action@v2
if: inputs.environment == 'production' && !contains(inputs.tag_name, '-')
with:
formula-name: gh
tag-name: ${{ inputs.tag_name }}
push-to: cli/homebrew-core
env:
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }}

View file

@ -1,222 +0,0 @@
name: goreleaser
on:
push:
tags:
- "v*"
permissions:
contents: write # publishing releases
repository-projects: write # move cards between columns
jobs:
goreleaser:
runs-on: ubuntu-20.04
steps:
- name: Checkout
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Set up Go 1.19
uses: actions/setup-go@v4
with:
go-version: 1.19
- name: Generate changelog
id: changelog
run: |
echo "tag-name=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
gh api repos/$GITHUB_REPOSITORY/releases/generate-notes \
-f tag_name="${GITHUB_REF#refs/tags/}" \
-f target_commitish=trunk \
-q .body > CHANGELOG.md
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Install osslsigncode
run: sudo apt-get install -y osslsigncode
- name: 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@v4
with:
version: "~1.13.1"
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@v3
with:
repository: github/cli.github.com
path: site
fetch-depth: 0
ssh-key: ${{secrets.SITE_SSH_KEY}}
- name: Update site man pages
env:
GIT_COMMITTER_NAME: cli automation
GIT_AUTHOR_NAME: cli automation
GIT_COMMITTER_EMAIL: noreply@github.com
GIT_AUTHOR_EMAIL: noreply@github.com
run: make site-bump
- name: Move project cards
continue-on-error: true
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
PENDING_COLUMN: 8189733
DONE_COLUMN: 7110130
run: |
api() { gh api -H 'accept: application/vnd.github.inertia-preview+json' "$@"; }
api-write() { [[ $GITHUB_REF == *-* ]] && echo "skipping: api $*" || api "$@"; }
cards=$(api --paginate projects/columns/$PENDING_COLUMN/cards | jq ".[].id")
for card in $cards; do
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: |
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 "${{secrets.GPG_KEYGRIP}}"
- name: Sign RPMs
run: |
cp script/rpmmacros ~/.rpmmacros
rpmsign --addsign dist/*.rpm
- name: Run createrepo
run: |
mkdir -p site/packages/rpm
cp dist/*.rpm site/packages/rpm/
./script/createrepo.sh
cp -r dist/repodata site/packages/rpm/
pushd site/packages/rpm
gpg --yes --detach-sign --armor repodata/repomd.xml
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
for release in $RELEASES; do
for file in dist/*.deb; do
reprepro --confdir="+b/script" includedeb "$release" "$file"
done
done
cp -a dists/ pool/ upload/
mkdir -p site/packages
cp -a upload/* site/packages/
- name: Publish site
env:
GIT_COMMITTER_NAME: cli automation
GIT_AUTHOR_NAME: cli automation
GIT_COMMITTER_EMAIL: noreply@github.com
GIT_AUTHOR_EMAIL: noreply@github.com
working-directory: ./site
run: |
git add packages
git commit -m "Add rpm and deb packages for ${GITHUB_REF#refs/tags/}"
if [[ $GITHUB_REF == *-* ]]; then
git log --oneline @{upstream}..
git diff --name-status @{upstream}..
else
git push
fi
msi:
needs: goreleaser
runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Download gh.exe
id: download_exe
shell: bash
run: |
hub release download "${GITHUB_REF#refs/tags/}" -i '*windows_amd64*.zip'
printf "zip=%s\n" *.zip >> $GITHUB_OUTPUT
unzip -o *.zip && rm -v *.zip
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Prepare PATH
id: setupmsbuild
uses: microsoft/setup-msbuild@v1.3.1
- name: Build MSI
id: buildmsi
shell: bash
env:
ZIP_FILE: ${{ steps.download_exe.outputs.zip }}
MSBUILD_PATH: ${{ steps.setupmsbuild.outputs.msbuildPath }}
run: |
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 "cert-file=%s\n" ".\\cert.pfx" >> $GITHUB_OUTPUT
env:
CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }}
- name: Sign MSI
env:
CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }}
EXE_FILE: ${{ steps.buildmsi.outputs.msi }}
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: |
tag_name="${GITHUB_REF#refs/tags/}"
hub release edit "$tag_name" -m "" -a "$MSI_FILE"
release_url="$(gh api repos/:owner/:repo/releases -q ".[]|select(.tag_name==\"${tag_name}\")|.url")"
publish_args=( -F draft=false )
if [[ $GITHUB_REF != *-* ]]; then
publish_args+=( -f discussion_category_name="$DISCUSSION_CATEGORY" )
fi
gh api -X PATCH "$release_url" "${publish_args[@]}"
env:
MSI_FILE: ${{ steps.buildmsi.outputs.msi }}
DISCUSSION_CATEGORY: General
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Bump homebrew-core formula
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@v3
with:
repository: cli/scoop-gh
path: scoop-gh
fetch-depth: 0
token: ${{secrets.UPLOAD_GITHUB_TOKEN}}
- name: Bump scoop bucket
shell: bash
run: |
hub release download "${GITHUB_REF#refs/tags/}" -i '*_checksums.txt'
script/scoop-gen "${GITHUB_REF#refs/tags/}" ./scoop-gh/gh.json < *_checksums.txt
git -C ./scoop-gh commit -m "gh ${GITHUB_REF#refs/tags/}" gh.json
if [[ $GITHUB_REF == *-* ]]; then
git -C ./scoop-gh show -m
else
git -C ./scoop-gh push
fi
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
GIT_COMMITTER_NAME: cli automation
GIT_AUTHOR_NAME: cli automation
GIT_COMMITTER_EMAIL: noreply@github.com
GIT_AUTHOR_EMAIL: noreply@github.com

5
.gitignore vendored
View file

@ -1,12 +1,17 @@
/bin
/share/bash-completion/completions
/share/fish/vendor_completions.d
/share/man/man1
/share/zsh/site-functions
/gh-cli
.envrc
/dist
/site
.github/**/node_modules
/CHANGELOG.md
/.goreleaser.generated.yml
/script/build
/script/build.exe
# VS Code
.vscode

View file

@ -7,56 +7,76 @@ release:
before:
hooks:
- go mod tidy
- make manpages GH_VERSION={{.Version}}
- >-
{{ if eq .Runtime.Goos "windows" }}echo{{ end }} make manpages GH_VERSION={{.Version}}
- >-
{{ if ne .Runtime.Goos "linux" }}echo{{ end }} make completions
builds:
- <<: &build_defaults
binary: bin/gh
main: ./cmd/gh
ldflags:
- -s -w -X github.com/cli/cli/v2/internal/build.Version={{.Version}} -X github.com/cli/cli/v2/internal/build.Date={{time "2006-01-02"}}
id: macos
- id: macos #build:macos
goos: [darwin]
goarch: [amd64, arm64]
hooks:
post:
- cmd: ./script/sign '{{ .Path }}'
output: true
binary: bin/gh
main: ./cmd/gh
ldflags:
- -s -w -X github.com/cli/cli/v2/internal/build.Version={{.Version}} -X github.com/cli/cli/v2/internal/build.Date={{time "2006-01-02"}}
- <<: *build_defaults
id: linux
- id: linux #build:linux
goos: [linux]
goarch: [386, arm, amd64, arm64]
env:
- CGO_ENABLED=0
binary: bin/gh
main: ./cmd/gh
ldflags:
- -s -w -X github.com/cli/cli/v2/internal/build.Version={{.Version}} -X github.com/cli/cli/v2/internal/build.Date={{time "2006-01-02"}}
- <<: *build_defaults
id: windows
- id: windows #build:windows
goos: [windows]
goarch: [386, amd64, arm64]
hooks:
post:
- cmd: ./script/sign-windows-executable.sh '{{ .Path }}'
output: false
- cmd: >-
{{ if eq .Runtime.Goos "windows" }}.\script\sign{{ else }}./script/sign{{ end }} '{{ .Path }}'
output: true
binary: bin/gh
main: ./cmd/gh
ldflags:
- -s -w -X github.com/cli/cli/v2/internal/build.Version={{.Version}} -X github.com/cli/cli/v2/internal/build.Date={{time "2006-01-02"}}
archives:
- id: nix
builds: [macos, linux]
<<: &archive_defaults
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
- id: linux-archive
builds: [linux]
name_template: "gh_{{ .Version }}_linux_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
wrap_in_directory: true
replacements:
darwin: macOS
format: tar.gz
rlcp: true
files:
- LICENSE
- ./share/man/man1/gh*.1
- id: windows
- id: macos-archive
builds: [macos]
name_template: "gh_{{ .Version }}_macOS_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
wrap_in_directory: true
format: zip
rlcp: true
files:
- LICENSE
- ./share/man/man1/gh*.1
- id: windows-archive
builds: [windows]
<<: *archive_defaults
name_template: "gh_{{ .Version }}_windows_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
wrap_in_directory: false
format: zip
rlcp: true
files:
- LICENSE
nfpms:
nfpms: #build:linux
- license: MIT
maintainer: GitHub
homepage: https://github.com/cli/cli
@ -70,3 +90,9 @@ nfpms:
contents:
- src: "./share/man/man1/gh*.1"
dst: "/usr/share/man/man1"
- src: "./share/bash-completion/completions/gh"
dst: "/usr/share/bash-completion/completions/gh"
- src: "./share/fish/vendor_completions.d/gh.fish"
dst: "/usr/share/fish/vendor_completions.d/gh.fish"
- src: "./share/zsh/site-functions/_gh"
dst: "/usr/share/zsh/site-functions/_gh"

View file

@ -6,26 +6,37 @@ CGO_LDFLAGS ?= $(filter -g -L% -l% -O%,${LDFLAGS})
export CGO_LDFLAGS
EXE =
ifeq ($(GOOS),windows)
ifeq ($(shell go env GOOS),windows)
EXE = .exe
endif
## The following tasks delegate to `script/build.go` so they can be run cross-platform.
.PHONY: bin/gh$(EXE)
bin/gh$(EXE): script/build
@script/build $@
bin/gh$(EXE): script/build$(EXE)
@script/build$(EXE) $@
script/build: script/build.go
script/build$(EXE): script/build.go
ifeq ($(EXE),)
GOOS= GOARCH= GOARM= GOFLAGS= CGO_ENABLED= go build -o $@ $<
else
go build -o $@ $<
endif
.PHONY: clean
clean: script/build
@script/build $@
clean: script/build$(EXE)
@$< $@
.PHONY: manpages
manpages: script/build
@script/build $@
manpages: script/build$(EXE)
@$< $@
.PHONY: completions
completions: bin/gh$(EXE)
mkdir -p ./share/bash-completion/completions ./share/fish/vendor_completions.d ./share/zsh/site-functions
bin/gh$(EXE) completion -s bash > ./share/bash-completion/completions/gh
bin/gh$(EXE) completion -s fish > ./share/fish/vendor_completions.d/gh.fish
bin/gh$(EXE) completion -s zsh > ./share/zsh/site-functions/_gh
# just a convenience task around `go test`
.PHONY: test
@ -60,15 +71,25 @@ endif
DESTDIR :=
prefix := /usr/local
bindir := ${prefix}/bin
mandir := ${prefix}/share/man
datadir := ${prefix}/share
mandir := ${datadir}/man
.PHONY: install
install: bin/gh manpages
install: bin/gh manpages completions
install -d ${DESTDIR}${bindir}
install -m755 bin/gh ${DESTDIR}${bindir}/
install -d ${DESTDIR}${mandir}/man1
install -m644 ./share/man/man1/* ${DESTDIR}${mandir}/man1/
install -d ${DESTDIR}${datadir}/bash-completion/completions
install -m644 ./share/bash-completion/completions/gh ${DESTDIR}${datadir}/bash-completion/completions/gh
install -d ${DESTDIR}${datadir}/fish/vendor_completions.d
install -m644 ./share/fish/vendor_completions.d/gh.fish ${DESTDIR}${datadir}/fish/vendor_completions.d/gh.fish
install -d ${DESTDIR}${datadir}/zsh/site-functions
install -m644 ./share/zsh/site-functions/_gh ${DESTDIR}${datadir}/zsh/site-functions/_gh
.PHONY: uninstall
uninstall:
rm -f ${DESTDIR}${bindir}/gh ${DESTDIR}${mandir}/man1/gh.1 ${DESTDIR}${mandir}/man1/gh-*.1
rm -f ${DESTDIR}${datadir}/bash-completion/completions/gh
rm -f ${DESTDIR}${datadir}/fish/vendor_completions.d/gh.fish
rm -f ${DESTDIR}${datadir}/zsh/site-functions/_gh

View file

@ -70,7 +70,7 @@ For more information, see [Linux & BSD installation](./docs/install_linux.md).
| `winget install --id GitHub.cli` | `winget upgrade --id GitHub.cli` |
> **Note**
> The Windows installer modifies your PATH. When using Windows Terminal, you will need to **open a new window** for the changes to take affect. (Simply opening a new tab will _not_ be sufficient.)
> The Windows installer modifies your PATH. When using Windows Terminal, you will need to **open a new window** for the changes to take effect. (Simply opening a new tab will _not_ be sufficient.)
#### scoop

View file

@ -11,8 +11,7 @@ import (
"strings"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/go-gh"
ghAPI "github.com/cli/go-gh/pkg/api"
ghAPI "github.com/cli/go-gh/v2/pkg/api"
)
const (
@ -40,11 +39,11 @@ func (c *Client) HTTP() *http.Client {
}
type GraphQLError struct {
ghAPI.GQLError
*ghAPI.GraphQLError
}
type HTTPError struct {
ghAPI.HTTPError
*ghAPI.HTTPError
scopesSuggestion string
}
@ -57,7 +56,7 @@ func (err HTTPError) ScopesSuggestion() string {
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)
gqlClient, err := ghAPI.NewGraphQLClient(opts)
if err != nil {
return err
}
@ -69,7 +68,7 @@ func (c Client) GraphQL(hostname string, query string, variables map[string]inte
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)
gqlClient, err := ghAPI.NewGraphQLClient(opts)
if err != nil {
return err
}
@ -81,7 +80,7 @@ func (c Client) Mutate(hostname, name string, mutation interface{}, variables ma
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)
gqlClient, err := ghAPI.NewGraphQLClient(opts)
if err != nil {
return err
}
@ -93,7 +92,7 @@ func (c Client) Query(hostname, name string, query interface{}, variables map[st
func (c Client) QueryWithContext(ctx context.Context, 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)
gqlClient, err := ghAPI.NewGraphQLClient(opts)
if err != nil {
return err
}
@ -103,7 +102,7 @@ func (c Client) QueryWithContext(ctx context.Context, hostname, name string, que
// 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)
restClient, err := ghAPI.NewRESTClient(opts)
if err != nil {
return err
}
@ -112,7 +111,7 @@ func (c Client) REST(hostname string, method string, p string, body io.Reader, d
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)
restClient, err := ghAPI.NewRESTClient(opts)
if err != nil {
return "", err
}
@ -157,14 +156,14 @@ func HandleHTTPError(resp *http.Response) error {
return handleResponse(ghAPI.HandleHTTPError(resp))
}
// handleResponse takes a ghAPI.HTTPError or ghAPI.GQLError and converts it into an
// handleResponse takes a ghAPI.HTTPError or ghAPI.GraphQLError and converts it into an
// HTTPError or GraphQLError respectively.
func handleResponse(err error) error {
if err == nil {
return nil
}
var restErr ghAPI.HTTPError
var restErr *ghAPI.HTTPError
if errors.As(err, &restErr) {
return HTTPError{
HTTPError: restErr,
@ -175,10 +174,10 @@ func handleResponse(err error) error {
}
}
var gqlErr ghAPI.GQLError
var gqlErr *ghAPI.GraphQLError
if errors.As(err, &gqlErr) {
return GraphQLError{
GQLError: gqlErr,
GraphQLError: gqlErr,
}
}

View file

@ -9,8 +9,7 @@ import (
"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"
ghAPI "github.com/cli/go-gh/v2/pkg/api"
)
type tokenGetter interface {
@ -55,7 +54,7 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
clientOpts.CacheTTL = opts.CacheTTL
}
client, err := gh.HTTPClient(&clientOpts)
client, err := ghAPI.NewHTTPClient(clientOpts)
if err != nil {
return nil, err
}

View file

@ -2,42 +2,414 @@ package api
import (
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestPullRequest_ChecksStatus(t *testing.T) {
pr := PullRequest{}
func TestChecksStatus_NoCheckRunsOrStatusContexts(t *testing.T) {
t.Parallel()
payload := `
{ "statusCheckRollup": { "nodes": [] } }
`
var pr PullRequest
require.NoError(t, json.Unmarshal([]byte(payload), &pr))
expectedChecksStatus := PullRequestChecksStatus{
Pending: 0,
Failing: 0,
Passing: 0,
Total: 0,
}
require.Equal(t, expectedChecksStatus, pr.ChecksStatus())
}
func TestChecksStatus_SummarisingCheckRuns(t *testing.T) {
t.Parallel()
tests := []struct {
name string
payload string
expectedChecksStatus PullRequestChecksStatus
}{
{
name: "QUEUED is treated as Pending",
payload: singleCheckRunWithStatus("QUEUED"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "IN_PROGRESS is treated as Pending",
payload: singleCheckRunWithStatus("IN_PROGRESS"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "WAITING is treated as Pending",
payload: singleCheckRunWithStatus("WAITING"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "PENDING is treated as Pending",
payload: singleCheckRunWithStatus("PENDING"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "REQUESTED is treated as Pending",
payload: singleCheckRunWithStatus("REQUESTED"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "COMPLETED with no conclusion is treated as Pending",
payload: singleCheckRunWithStatus("COMPLETED"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "COMPLETED / STARTUP_FAILURE is treated as Pending",
payload: singleCompletedCheckRunWithConclusion("STARTUP_FAILURE"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "COMPLETED / STALE is treated as Pending",
payload: singleCompletedCheckRunWithConclusion("STALE"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "COMPLETED / SUCCESS is treated as Passing",
payload: singleCompletedCheckRunWithConclusion("SUCCESS"),
expectedChecksStatus: PullRequestChecksStatus{Passing: 1, Total: 1},
},
{
name: "COMPLETED / NEUTRAL is treated as Passing",
payload: singleCompletedCheckRunWithConclusion("NEUTRAL"),
expectedChecksStatus: PullRequestChecksStatus{Passing: 1, Total: 1},
},
{
name: "COMPLETED / SKIPPED is treated as Passing",
payload: singleCompletedCheckRunWithConclusion("SKIPPED"),
expectedChecksStatus: PullRequestChecksStatus{Passing: 1, Total: 1},
},
{
name: "COMPLETED / ACTION_REQUIRED is treated as Failing",
payload: singleCompletedCheckRunWithConclusion("ACTION_REQUIRED"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "COMPLETED / TIMED_OUT is treated as Failing",
payload: singleCompletedCheckRunWithConclusion("TIMED_OUT"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "COMPLETED / CANCELLED is treated as Failing",
payload: singleCompletedCheckRunWithConclusion("CANCELLED"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "COMPLETED / CANCELLED is treated as Failing",
payload: singleCompletedCheckRunWithConclusion("CANCELLED"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "COMPLETED / FAILURE is treated as Failing",
payload: singleCompletedCheckRunWithConclusion("FAILURE"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "Unrecognized Status are treated as Pending",
payload: singleCheckRunWithStatus("AnUnrecognizedStatusJustForThisTest"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "Unrecognized Conclusions are treated as Pending",
payload: singleCompletedCheckRunWithConclusion("AnUnrecognizedConclusionJustForThisTest"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var pr PullRequest
require.NoError(t, json.Unmarshal([]byte(tt.payload), &pr))
require.Equal(t, tt.expectedChecksStatus, pr.ChecksStatus())
})
}
}
func TestChecksStatus_SummarisingStatusContexts(t *testing.T) {
t.Parallel()
tests := []struct {
name string
payload string
expectedChecksStatus PullRequestChecksStatus
}{
{
name: "EXPECTED is treated as Pending",
payload: singleStatusContextWithState("EXPECTED"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "PENDING is treated as Pending",
payload: singleStatusContextWithState("PENDING"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
{
name: "SUCCESS is treated as Passing",
payload: singleStatusContextWithState("SUCCESS"),
expectedChecksStatus: PullRequestChecksStatus{Passing: 1, Total: 1},
},
{
name: "ERROR is treated as Failing",
payload: singleStatusContextWithState("ERROR"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "FAILURE is treated as Failing",
payload: singleStatusContextWithState("FAILURE"),
expectedChecksStatus: PullRequestChecksStatus{Failing: 1, Total: 1},
},
{
name: "Unrecognized States are treated as Pending",
payload: singleStatusContextWithState("AnUnrecognizedStateJustForThisTest"),
expectedChecksStatus: PullRequestChecksStatus{Pending: 1, Total: 1},
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var pr PullRequest
require.NoError(t, json.Unmarshal([]byte(tt.payload), &pr))
require.Equal(t, tt.expectedChecksStatus, pr.ChecksStatus())
})
}
}
func TestChecksStatus_SummarisingCheckRunsAndStatusContexts(t *testing.T) {
t.Parallel()
// This might look a bit intimidating, but we're just inserting three nodes
// into the rollup, two completed check run nodes and one status context node.
payload := fmt.Sprintf(`
{ "statusCheckRollup": { "nodes": [{ "commit": {
"statusCheckRollup": {
"contexts": {
"nodes": [
{ "state": "SUCCESS" },
{ "state": "PENDING" },
{ "state": "FAILURE" },
{ "status": "IN_PROGRESS",
"conclusion": null },
{ "status": "COMPLETED",
"conclusion": "SUCCESS" },
{ "status": "COMPLETED",
"conclusion": "FAILURE" },
{ "status": "COMPLETED",
"conclusion": "ACTION_REQUIRED" },
{ "status": "COMPLETED",
"conclusion": "STALE" }
%s,
%s,
%s
]
}
}
} }] } }
`,
completedCheckRunNode("SUCCESS"),
statusContextNode("PENDING"),
completedCheckRunNode("FAILURE"),
)
var pr PullRequest
require.NoError(t, json.Unmarshal([]byte(payload), &pr))
expectedChecksStatus := PullRequestChecksStatus{
Pending: 1,
Failing: 1,
Passing: 1,
Total: 3,
}
require.Equal(t, expectedChecksStatus, pr.ChecksStatus())
}
func TestChecksStatus_SummarisingCheckRunAndStatusContextCountsByState(t *testing.T) {
t.Parallel()
payload := `
{ "statusCheckRollup": { "nodes": [{ "commit": {
"statusCheckRollup": {
"contexts": {
"checkRunCount": 14,
"checkRunCountsByState": [
{
"state": "ACTION_REQUIRED",
"count": 1
},
{
"state": "CANCELLED",
"count": 1
},
{
"state": "COMPLETED",
"count": 1
},
{
"state": "FAILURE",
"count": 1
},
{
"state": "IN_PROGRESS",
"count": 1
},
{
"state": "NEUTRAL",
"count": 1
},
{
"state": "PENDING",
"count": 1
},
{
"state": "QUEUED",
"count": 1
},
{
"state": "SKIPPED",
"count": 1
},
{
"state": "STALE",
"count": 1
},
{
"state": "STARTUP_FAILURE",
"count": 1
},
{
"state": "SUCCESS",
"count": 1
},
{
"state": "TIMED_OUT",
"count": 1
},
{
"state": "WAITING",
"count": 1
},
{
"state": "AnUnrecognizedStateJustForThisTest",
"count": 1
}
],
"statusContextCount": 6,
"statusContextCountsByState": [
{
"state": "EXPECTED",
"count": 1
},
{
"state": "ERROR",
"count": 1
},
{
"state": "FAILURE",
"count": 1
},
{
"state": "PENDING",
"count": 1
},
{
"state": "SUCCESS",
"count": 1
},
{
"state": "AnUnrecognizedStateJustForThisTest",
"count": 1
}
]
}
}
} }] } }
`
err := json.Unmarshal([]byte(payload), &pr)
assert.NoError(t, err)
checks := pr.ChecksStatus()
assert.Equal(t, 8, checks.Total)
assert.Equal(t, 3, checks.Pending)
assert.Equal(t, 3, checks.Failing)
assert.Equal(t, 2, checks.Passing)
var pr PullRequest
require.NoError(t, json.Unmarshal([]byte(payload), &pr))
expectedChecksStatus := PullRequestChecksStatus{
Pending: 11,
Failing: 6,
Passing: 4,
Total: 20,
}
require.Equal(t, expectedChecksStatus, pr.ChecksStatus())
}
// Note that it would be incorrect to provide a status of COMPLETED here
// as the conclusion is always set to null. If you want a COMPLETED status,
// use `singleCompletedCheckRunWithConclusion`.
func singleCheckRunWithStatus(status string) string {
return fmt.Sprintf(`
{ "statusCheckRollup": { "nodes": [{ "commit": {
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"__typename": "CheckRun",
"status": "%s",
"conclusion": null
}
]
}
}
} }] } }
`, status)
}
func singleCompletedCheckRunWithConclusion(conclusion string) string {
return fmt.Sprintf(`
{ "statusCheckRollup": { "nodes": [{ "commit": {
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"__typename": "CheckRun",
"status": "COMPLETED",
"conclusion": "%s"
}
]
}
}
} }] } }
`, conclusion)
}
func singleStatusContextWithState(state string) string {
return fmt.Sprintf(`
{ "statusCheckRollup": { "nodes": [{ "commit": {
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"__typename": "StatusContext",
"state": "%s"
}
]
}
}
} }] } }
`, state)
}
func completedCheckRunNode(conclusion string) string {
return fmt.Sprintf(`
{
"__typename": "CheckRun",
"status": "COMPLETED",
"conclusion": "%s"
}`, conclusion)
}
func statusContextNode(state string) string {
return fmt.Sprintf(`
{
"__typename": "StatusContext",
"state": "%s"
}`, state)
}

View file

@ -4,63 +4,16 @@ import (
"fmt"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/shurcooL/githubv4"
)
type LinkedBranch struct {
ID string
BranchName string
RepoUrl string
URL 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 {
func CreateLinkedBranch(client *Client, host string, repoID, issueID, branchID, branchName string) (string, error) {
var mutation struct {
CreateLinkedBranch struct {
LinkedBranch struct {
ID string
@ -68,91 +21,76 @@ func CreateBranchIssueReference(client *Client, repo *Repository, params map[str
Name string
}
}
}
}{}
} `graphql:"createLinkedBranch(input: $input)"`
}
err := client.GraphQL(repo.RepoHost(), query, inputParams, &result)
input := githubv4.CreateLinkedBranchInput{
IssueID: githubv4.ID(issueID),
Oid: githubv4.GitObjectID(branchID),
}
if repoID != "" {
repo := githubv4.ID(repoID)
input.RepositoryID = &repo
}
if branchName != "" {
name := githubv4.String(branchName)
input.Name = &name
}
variables := map[string]interface{}{
"input": input,
}
err := client.Mutate(host, "CreateLinkedBranch", &mutation, variables)
if err != nil {
return nil, err
return "", err
}
ref := LinkedBranch{
ID: result.CreateLinkedBranch.LinkedBranch.ID,
BranchName: result.CreateLinkedBranch.LinkedBranch.Ref.Name,
}
return &ref, nil
return mutation.CreateLinkedBranch.LinkedBranch.Ref.Name, 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 {
var query struct {
Repository struct {
Issue struct {
LinkedBranches struct {
Edges []struct {
Node struct {
Ref struct {
Name string
Repository struct {
NameWithOwner string
Url string
}
Nodes []struct {
Ref struct {
Name string
Repository struct {
Url string
}
}
}
}
}
}
}{}
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
var branchNames []LinkedBranch
if err != nil {
return branchNames, err
} `graphql:"linkedBranches(first: 30)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
for _, edge := range result.Repository.Issue.LinkedBranches.Edges {
branch := LinkedBranch{
BranchName: edge.Node.Ref.Name,
RepoUrl: edge.Node.Ref.Repository.Url,
}
variables := map[string]interface{}{
"number": githubv4.Int(issueNumber),
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
}
if err := client.Query(repo.RepoHost(), "ListLinkedBranches", &query, variables); err != nil {
return []LinkedBranch{}, err
}
var branchNames []LinkedBranch
for _, node := range query.Repository.Issue.LinkedBranches.Nodes {
branch := LinkedBranch{
BranchName: node.Ref.Name,
URL: fmt.Sprintf("%s/tree/%s", node.Ref.Repository.Url, node.Ref.Name),
}
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 {
func CheckLinkedBranchFeature(client *Client, host string) error {
var query struct {
Name struct {
Fields []struct {
Name string
@ -160,44 +98,21 @@ func CheckLinkedBranchFeature(client *Client, host string) (err error) {
} `graphql:"LinkedBranch: __type(name: \"LinkedBranch\")"`
}
err = client.Query(host, "LinkedBranch_fields", &featureDetection, nil)
if err != nil {
if err := client.Query(host, "LinkedBranchFeature", &query, nil); err != nil {
return err
}
if len(featureDetection.Name.Fields) == 0 {
if len(query.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 {
func FindRepoBranchID(client *Client, repo ghrepo.Interface, ref string) (string, string, error) {
var query struct {
Repository struct {
Id string
DefaultBranchRef struct {
Target struct {
Oid string
@ -207,13 +122,24 @@ func FindBaseOid(client *Client, repo *Repository, ref string) (string, string,
Target struct {
Oid string
}
}
}
}{}
} `graphql:"ref(qualifiedName: $ref)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
if err != nil {
variables := map[string]interface{}{
"ref": githubv4.String(ref),
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
}
if err := client.Query(repo.RepoHost(), "FindRepoBranchID", &query, variables); err != nil {
return "", "", err
}
return result.Repository.Ref.Target.Oid, result.Repository.DefaultBranchRef.Target.Oid, nil
branchID := query.Repository.Ref.Target.Oid
if branchID == "" {
branchID = query.Repository.DefaultBranchRef.Target.Oid
}
return query.Repository.Id, branchID, nil
}

View file

@ -39,6 +39,8 @@ type PullRequest struct {
ClosedAt *time.Time
MergedAt *time.Time
AutoMergeRequest *AutoMergeRequest
MergeCommit *Commit
PotentialMergeCommit *Commit
@ -95,7 +97,84 @@ type CommitStatusCheckRollup struct {
Contexts CheckContexts
}
// https://docs.github.com/en/graphql/reference/enums#checkrunstate
type CheckRunState string
const (
CheckRunStateActionRequired CheckRunState = "ACTION_REQUIRED"
CheckRunStateCancelled CheckRunState = "CANCELLED"
CheckRunStateCompleted CheckRunState = "COMPLETED"
CheckRunStateFailure CheckRunState = "FAILURE"
CheckRunStateInProgress CheckRunState = "IN_PROGRESS"
CheckRunStateNeutral CheckRunState = "NEUTRAL"
CheckRunStatePending CheckRunState = "PENDING"
CheckRunStateQueued CheckRunState = "QUEUED"
CheckRunStateSkipped CheckRunState = "SKIPPED"
CheckRunStateStale CheckRunState = "STALE"
CheckRunStateStartupFailure CheckRunState = "STARTUP_FAILURE"
CheckRunStateSuccess CheckRunState = "SUCCESS"
CheckRunStateTimedOut CheckRunState = "TIMED_OUT"
CheckRunStateWaiting CheckRunState = "WAITING"
)
type CheckRunCountByState struct {
State CheckRunState
Count int
}
// https://docs.github.com/en/graphql/reference/enums#statusstate
type StatusState string
const (
StatusStateError StatusState = "ERROR"
StatusStateExpected StatusState = "EXPECTED"
StatusStateFailure StatusState = "FAILURE"
StatusStatePending StatusState = "PENDING"
StatusStateSuccess StatusState = "SUCCESS"
)
type StatusContextCountByState struct {
State StatusState
Count int
}
// https://docs.github.com/en/graphql/reference/enums#checkstatusstate
type CheckStatusState string
const (
CheckStatusStateCompleted CheckStatusState = "COMPLETED"
CheckStatusStateInProgress CheckStatusState = "IN_PROGRESS"
CheckStatusStatePending CheckStatusState = "PENDING"
CheckStatusStateQueued CheckStatusState = "QUEUED"
CheckStatusStateRequested CheckStatusState = "REQUESTED"
CheckStatusStateWaiting CheckStatusState = "WAITING"
)
// https://docs.github.com/en/graphql/reference/enums#checkconclusionstate
type CheckConclusionState string
const (
CheckConclusionStateActionRequired CheckConclusionState = "ACTION_REQUIRED"
CheckConclusionStateCancelled CheckConclusionState = "CANCELLED"
CheckConclusionStateFailure CheckConclusionState = "FAILURE"
CheckConclusionStateNeutral CheckConclusionState = "NEUTRAL"
CheckConclusionStateSkipped CheckConclusionState = "SKIPPED"
CheckConclusionStateStale CheckConclusionState = "STALE"
CheckConclusionStateStartupFailure CheckConclusionState = "STARTUP_FAILURE"
CheckConclusionStateSuccess CheckConclusionState = "SUCCESS"
CheckConclusionStateTimedOut CheckConclusionState = "TIMED_OUT"
)
type CheckContexts struct {
// These fields are available on newer versions of the GraphQL API
// to support summary counts by state
CheckRunCount int
CheckRunCountsByState []CheckRunCountByState
StatusContextCount int
StatusContextCountsByState []StatusContextCountByState
// These are available on older versions and provide more details
// required for checks
Nodes []CheckContext
PageInfo struct {
HasNextPage bool
@ -104,31 +183,38 @@ type CheckContexts struct {
}
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"`
TypeName string `json:"__typename"`
Name string `json:"name"`
IsRequired bool `json:"isRequired"`
CheckSuite CheckSuite `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"`
Conclusion CheckConclusionState `json:"conclusion"`
StartedAt time.Time `json:"startedAt"`
CompletedAt time.Time `json:"completedAt"`
DetailsURL string `json:"detailsUrl"`
/* StatusContext fields */
Context string `json:"context"`
Context string `json:"context"`
Description string `json:"description"`
// EXPECTED ERROR FAILURE PENDING SUCCESS
State string `json:"state"`
TargetURL string `json:"targetUrl"`
CreatedAt time.Time `json:"createdAt"`
State StatusState `json:"state"`
TargetURL string `json:"targetUrl"`
CreatedAt time.Time `json:"createdAt"`
}
type CheckSuite struct {
WorkflowRun WorkflowRun `json:"workflowRun"`
}
type WorkflowRun struct {
Event string `json:"event"`
Workflow Workflow `json:"workflow"`
}
type Workflow struct {
Name string `json:"name"`
}
type PRRepository struct {
@ -136,6 +222,16 @@ type PRRepository struct {
Name string `json:"name"`
}
type AutoMergeRequest struct {
AuthorEmail *string `json:"authorEmail"`
CommitBody *string `json:"commitBody"`
CommitHeadline *string `json:"commitHeadline"`
// MERGE, REBASE, SQUASH
MergeMethod string `json:"mergeMethod"`
EnabledAt time.Time `json:"enabledAt"`
EnabledBy Author `json:"enabledBy"`
}
// Commit loads just the commit SHA and nothing else
type Commit struct {
OID string `json:"oid"`
@ -249,33 +345,136 @@ type PullRequestChecksStatus struct {
Total int
}
func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) {
func (pr *PullRequest) ChecksStatus() PullRequestChecksStatus {
var summary PullRequestChecksStatus
if len(pr.StatusCheckRollup.Nodes) == 0 {
return
return summary
}
commit := pr.StatusCheckRollup.Nodes[0].Commit
for _, c := range commit.StatusCheckRollup.Contexts.Nodes {
state := c.State // StatusContext
if state == "" {
// CheckRun
if c.Status == "COMPLETED" {
state = c.Conclusion
} else {
state = c.Status
contexts := pr.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts
// If this commit has counts by state then we can summarise check status from those
if len(contexts.CheckRunCountsByState) != 0 && len(contexts.StatusContextCountsByState) != 0 {
summary.Total = contexts.CheckRunCount + contexts.StatusContextCount
for _, countByState := range contexts.CheckRunCountsByState {
switch parseCheckStatusFromCheckRunState(countByState.State) {
case passing:
summary.Passing += countByState.Count
case failing:
summary.Failing += countByState.Count
default:
summary.Pending += countByState.Count
}
}
switch state {
case "SUCCESS", "NEUTRAL", "SKIPPED":
summary.Passing++
case "ERROR", "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED":
summary.Failing++
default: // "EXPECTED", "REQUESTED", "WAITING", "QUEUED", "PENDING", "IN_PROGRESS", "STALE"
summary.Pending++
for _, countByState := range contexts.StatusContextCountsByState {
switch parseCheckStatusFromStatusState(countByState.State) {
case passing:
summary.Passing += countByState.Count
case failing:
summary.Failing += countByState.Count
default:
summary.Pending += countByState.Count
}
}
return summary
}
// If we don't have the counts by state, then we'll need to summarise by looking at the more detailed contexts
for _, c := range contexts.Nodes {
// Nodes are a discriminated union of CheckRun or StatusContext and we can match on
// the TypeName to narrow the type.
if c.TypeName == "CheckRun" {
// https://docs.github.com/en/graphql/reference/enums#checkstatusstate
// If the status is completed then we can check the conclusion field
if c.Status == "COMPLETED" {
switch parseCheckStatusFromCheckConclusionState(c.Conclusion) {
case passing:
summary.Passing++
case failing:
summary.Failing++
default:
summary.Pending++
}
// otherwise we're in some form of pending state:
// "COMPLETED", "IN_PROGRESS", "PENDING", "QUEUED", "REQUESTED", "WAITING" or otherwise unknown
} else {
summary.Pending++
}
} else { // c.TypeName == StatusContext
switch parseCheckStatusFromStatusState(c.State) {
case passing:
summary.Passing++
case failing:
summary.Failing++
default:
summary.Pending++
}
}
summary.Total++
}
return
return summary
}
type checkStatus int
const (
passing checkStatus = iota
failing
pending
)
func parseCheckStatusFromStatusState(state StatusState) checkStatus {
switch state {
case StatusStateSuccess:
return passing
case StatusStateFailure, StatusStateError:
return failing
case StatusStateExpected, StatusStatePending:
return pending
// Currently, we treat anything unknown as pending, which includes any future unknown
// states we might get back from the API. It might be interesting to do some work to add an additional
// unknown state.
default:
return pending
}
}
func parseCheckStatusFromCheckRunState(state CheckRunState) checkStatus {
switch state {
case CheckRunStateNeutral, CheckRunStateSkipped, CheckRunStateSuccess:
return passing
case CheckRunStateActionRequired, CheckRunStateCancelled, CheckRunStateFailure, CheckRunStateTimedOut:
return failing
case CheckRunStateCompleted, CheckRunStateInProgress, CheckRunStatePending, CheckRunStateQueued,
CheckRunStateStale, CheckRunStateStartupFailure, CheckRunStateWaiting:
return pending
// Currently, we treat anything unknown as pending, which includes any future unknown
// states we might get back from the API. It might be interesting to do some work to add an additional
// unknown state.
default:
return pending
}
}
func parseCheckStatusFromCheckConclusionState(state CheckConclusionState) checkStatus {
switch state {
case CheckConclusionStateNeutral, CheckConclusionStateSkipped, CheckConclusionStateSuccess:
return passing
case CheckConclusionStateActionRequired, CheckConclusionStateCancelled, CheckConclusionStateFailure, CheckConclusionStateTimedOut:
return failing
case CheckConclusionStateStale, CheckConclusionStateStartupFailure:
return pending
// Currently, we treat anything unknown as pending, which includes any future unknown
// states we might get back from the API. It might be interesting to do some work to add an additional
// unknown state.
default:
return pending
}
}
func (pr *PullRequest) DisplayableReviews() PullRequestReviews {

View file

@ -255,7 +255,7 @@ func CurrentUserProjectsV2(client *Client, hostname string) ([]ProjectV2, error)
// When querying ProjectsV2 fields we generally dont want to show the user
// scope errors and field does not exist errors. ProjectsV2IgnorableError
// checks against known error strings to see if an error can be safely ignored.
// Due to the fact that the GQLClient can return multiple types of errors
// Due to the fact that the GraphQLClient can return multiple types of errors
// this uses brittle string comparison to check against the known error strings.
func ProjectsV2IgnorableError(err error) bool {
msg := err.Error()

View file

@ -16,8 +16,7 @@ import (
"golang.org/x/sync/errgroup"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/go-gh/pkg/api"
ghAPI "github.com/cli/go-gh/pkg/api"
ghAPI "github.com/cli/go-gh/v2/pkg/api"
"github.com/shurcooL/githubv4"
)
@ -264,8 +263,8 @@ func FetchRepository(client *Client, repo ghrepo.Interface, fields []string) (*R
// guaranteed to happen when an authentication token with insufficient permissions is being used.
if result.Repository == nil {
return nil, GraphQLError{
GQLError: ghAPI.GQLError{
Errors: []ghAPI.GQLErrorItem{{
GraphQLError: &ghAPI.GraphQLError{
Errors: []ghAPI.GraphQLErrorItem{{
Type: "NOT_FOUND",
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
}},
@ -317,8 +316,8 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
// guaranteed to happen when an authentication token with insufficient permissions is being used.
if result.Repository == nil {
return nil, GraphQLError{
GQLError: ghAPI.GQLError{
Errors: []ghAPI.GQLErrorItem{{
GraphQLError: &ghAPI.GraphQLError{
Errors: []ghAPI.GraphQLErrorItem{{
Type: "NOT_FOUND",
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
}},
@ -1372,7 +1371,6 @@ func GetRepoIDs(client *Client, host string, repositories []ghrepo.Interface) ([
return result, nil
}
// RenameRepo renames the repository on GitHub and returns the renamed repository
func RepoExists(client *Client, repo ghrepo.Interface) (bool, error) {
path := fmt.Sprintf("%srepos/%s/%s", ghinstance.RESTPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName())
@ -1387,6 +1385,6 @@ func RepoExists(client *Client, repo ghrepo.Interface) (bool, error) {
case 404:
return false, nil
default:
return false, api.HandleHTTPError(resp)
return false, ghAPI.HandleHTTPError(resp)
}
}

View file

@ -132,7 +132,42 @@ var prCommits = shortenQuery(`
}
`)
func StatusCheckRollupGraphQL(after string) string {
var autoMergeRequest = shortenQuery(`
autoMergeRequest {
authorEmail,
commitBody,
commitHeadline,
mergeMethod,
enabledAt,
enabledBy{login,...on User{id,name}}
}
`)
func StatusCheckRollupGraphQLWithCountByState() string {
return shortenQuery(`
statusCheckRollup: commits(last: 1) {
nodes {
commit {
statusCheckRollup {
contexts {
checkRunCount,
checkRunCountsByState {
state,
count
},
statusContextCount,
statusContextCountsByState {
state,
count
}
}
}
}
}
}`)
}
func StatusCheckRollupGraphQLWithoutCountByState(after string) string {
var afterClause string
if after != "" {
afterClause = ",after:" + after
@ -149,7 +184,8 @@ func StatusCheckRollupGraphQL(after string) string {
context,
state,
targetUrl,
createdAt
createdAt,
description
},
...on CheckRun {
name,
@ -187,11 +223,12 @@ func RequiredStatusCheckRollupGraphQL(prID, after string) string {
state,
targetUrl,
createdAt,
description,
isRequired(pullRequestId: %[2]s)
},
...on CheckRun {
name,
checkSuite{workflowRun{workflow{name}}},
checkSuite{workflowRun{event,workflow{name}}},
status,
conclusion,
startedAt,
@ -231,6 +268,7 @@ var IssueFields = []string{
var PullRequestFields = append(IssueFields,
"additions",
"autoMergeRequest",
"baseRefName",
"changedFiles",
"commits",
@ -285,6 +323,8 @@ func IssueGraphQL(fields []string) string {
q = append(q, `mergeCommit{oid}`)
case "potentialMergeCommit":
q = append(q, `potentialMergeCommit{oid}`)
case "autoMergeRequest":
q = append(q, autoMergeRequest)
case "comments":
q = append(q, issueComments)
case "lastComment": // pseudo-field
@ -306,7 +346,9 @@ func IssueGraphQL(fields []string) string {
case "requiresStrictStatusChecks": // pseudo-field
q = append(q, `baseRef{branchProtectionRule{requiresStrictStatusChecks}}`)
case "statusCheckRollup":
q = append(q, StatusCheckRollupGraphQL(""))
q = append(q, StatusCheckRollupGraphQLWithoutCountByState(""))
case "statusCheckRollupWithCountByState": // pseudo-field
q = append(q, StatusCheckRollupGraphQLWithCountByState())
default:
q = append(q, field)
}
@ -372,6 +414,7 @@ var RepositoryFields = []string{
"isInOrganization",
"isMirror",
"isPrivate",
"visibility",
"isTemplate",
"isUserConfigurationRepository",
"licenseInfo",

View file

@ -47,9 +47,9 @@ type sanitizer struct {
addEscape bool
}
// Transform uses a sliding window alogorithm to detect C0 and C1
// Transform uses a sliding window algorithm to detect C0 and C1
// ASCII control sequences as they are read and replaces them
// with equivelent inert characters. Characters that are not part
// with equivalent inert characters. Characters that are not part
// of a control sequence are not modified.
func (t *sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
lSrc := len(src)

View file

@ -30,9 +30,5 @@
<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>

View file

@ -2,13 +2,17 @@ package main
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/docs"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/root"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/pflag"
)
@ -41,9 +45,13 @@ func run(args []string) error {
}
ios, _, _, _ := iostreams.Test()
rootCmd := root.NewCmdRoot(&cmdutil.Factory{
rootCmd, _ := root.NewCmdRoot(&cmdutil.Factory{
IOStreams: ios,
Browser: &browser{},
Config: func() (config.Config, error) {
return config.NewFromString(""), nil
},
ExtensionManager: &em{},
}, "", "")
rootCmd.InitDefaultHelpCmd()
@ -79,8 +87,42 @@ func linkHandler(name string) string {
return fmt.Sprintf("./%s", strings.TrimSuffix(name, ".md"))
}
// Implements browser.Browser interface.
type browser struct{}
func (b *browser) Browse(url string) error {
func (b *browser) Browse(_ string) error {
return nil
}
// Implements extensions.ExtensionManager interface.
type em struct{}
func (e *em) List() []extensions.Extension {
return nil
}
func (e *em) Install(_ ghrepo.Interface, _ string) error {
return nil
}
func (e *em) InstallLocal(_ string) error {
return nil
}
func (e *em) Upgrade(_ string, _ bool) error {
return nil
}
func (e *em) Remove(_ string) error {
return nil
}
func (e *em) Dispatch(_ []string, _ io.Reader, _, _ io.Writer) (bool, error) {
return false, nil
}
func (e *em) Create(_ string, _ extensions.ExtTemplateType) error {
return nil
}
func (e *em) EnableDryRunMode() {}

View file

@ -14,16 +14,10 @@ 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/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"
@ -80,9 +74,6 @@ func mainRun() exitCode {
surveyCore.TemplateFuncsWithColor["color"] = func(style string) string {
switch style {
case "white":
if cmdFactory.IOStreams.ColorSupport256() {
return fmt.Sprintf("\x1b[%d;5;%dm", 38, 242)
}
return ansi.ColorCode("default")
default:
return ansi.ColorCode(style)
@ -96,11 +87,9 @@ func mainRun() exitCode {
cobra.MousetrapHelpText = ""
}
rootCmd := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
cfg, err := cmdFactory.Config()
rootCmd, err := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
if err != nil {
fmt.Fprintf(stderr, "failed to read configuration: %s\n", err)
fmt.Fprintf(stderr, "failed to create root command: %s\n", err)
return exitError
}
@ -109,110 +98,10 @@ func mainRun() exitCode {
expandedArgs = os.Args[1:]
}
// translate `gh help <command>` to `gh <command> --help` for extensions
if len(expandedArgs) == 2 && expandedArgs[0] == "help" && !hasCommand(rootCmd, expandedArgs[1:]) {
expandedArgs = []string{expandedArgs[1], "--help"}
}
if !hasCommand(rootCmd, expandedArgs) {
originalArgs := expandedArgs
isShell := false
argsForExpansion := append([]string{"gh"}, expandedArgs...)
expandedArgs, isShell, err = expand.ExpandAlias(cfg, argsForExpansion, nil)
if err != nil {
fmt.Fprintf(stderr, "failed to process aliases: %s\n", err)
return exitError
}
if hasDebug {
fmt.Fprintf(stderr, "%v -> %v\n", originalArgs, expandedArgs)
}
if isShell {
exe, err := safeexec.LookPath(expandedArgs[0])
if err != nil {
fmt.Fprintf(stderr, "failed to run external command: %s", err)
return exitError
}
externalCmd := exec.Command(exe, expandedArgs[1:]...)
externalCmd.Stderr = os.Stderr
externalCmd.Stdout = os.Stdout
externalCmd.Stdin = os.Stdin
preparedCmd := run.PrepareCmd(externalCmd)
err = preparedCmd.Run()
if err != nil {
var execError *exec.ExitError
if errors.As(err, &execError) {
return exitCode(execError.ExitCode())
}
fmt.Fprintf(stderr, "failed to run external command: %s\n", err)
return exitError
}
return exitOK
} else if len(expandedArgs) > 0 && !hasCommand(rootCmd, expandedArgs) {
extensionManager := cmdFactory.ExtensionManager
if found, err := extensionManager.Dispatch(expandedArgs, os.Stdin, os.Stdout, os.Stderr); err != nil {
var execError *exec.ExitError
if errors.As(err, &execError) {
return exitCode(execError.ExitCode())
}
fmt.Fprintf(stderr, "failed to run extension: %s\n", err)
return exitError
} else if found {
return exitOK
}
}
}
// provide completions for aliases and extensions
rootCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
var results []string
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() {
if strings.HasPrefix(ext.Name(), toComplete) {
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
}
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.Fprint(stderr, authHelp())
return authError
}
return nil
// translate `gh help <command>` to `gh <command> --help` for extensions.
if len(expandedArgs) >= 2 && expandedArgs[0] == "help" && isExtensionCommand(rootCmd, expandedArgs[1:]) {
expandedArgs = expandedArgs[1:]
expandedArgs = append(expandedArgs, "--help")
}
rootCmd.SetArgs(expandedArgs)
@ -220,6 +109,8 @@ func mainRun() exitCode {
if cmd, err := rootCmd.ExecuteContextC(ctx); err != nil {
var pagerPipeError *iostreams.ErrClosedPagerPipe
var noResultsError cmdutil.NoResultsError
var extError *root.ExternalCommandExitError
var authError *root.AuthError
if err == cmdutil.SilentError {
return exitError
} else if cmdutil.IsUserCancellation(err) {
@ -228,7 +119,7 @@ func mainRun() exitCode {
fmt.Fprint(stderr, "\n")
}
return exitCancel
} else if errors.Is(err, authError) {
} else if errors.As(err, &authError) {
return exitAuth
} else if errors.As(err, &pagerPipeError) {
// ignore the error raised when piping to a closed pager
@ -239,6 +130,9 @@ func mainRun() exitCode {
}
// no results is not a command failure
return exitOK
} else if errors.As(err, &extError) {
// pass on exit codes from extensions and shell aliases
return exitCode(extError.ExitCode())
}
printError(stderr, err, cmd, hasDebug)
@ -287,10 +181,10 @@ func mainRun() exitCode {
return exitOK
}
// hasCommand returns true if args resolve to a built-in command
func hasCommand(rootCmd *cobra.Command, args []string) bool {
c, _, err := rootCmd.Traverse(args)
return err == nil && c != rootCmd
// isExtensionCommand returns true if args resolve to an extension command.
func isExtensionCommand(rootCmd *cobra.Command, args []string) bool {
c, _, err := rootCmd.Find(args)
return err == nil && c != nil && c.GroupID == "extension"
}
func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
@ -315,27 +209,6 @@ 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

View file

@ -1,36 +1,40 @@
# Releasing
Our build system automatically compiles and attaches cross-platform binaries to any git tag named `vX.Y.Z`. The changelog is [generated from git commit log](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes).
To initiate a new production deployment:
```sh
script/release vX.Y.Z
```
See `script/release --help` for more information.
Users who run official builds of `gh` on their machines will get notified about the new version within a 24 hour period.
> **Note:**
> Every production release will request an approval by the select few people before it can proceed.
To test out the build system, publish a prerelease tag with a name such as `vX.Y.Z-pre.0` or `vX.Y.Z-rc.1`. Note that such a release will still be public, but it will be marked as a "prerelease", meaning that it will never show up as a "latest" release nor trigger upgrade notifications for users.
What this does is:
- Builds Linux binaries on Ubuntu;
- Builds and signs Windows binaries on Windows;
- Builds, signs, and notarizes macOS binaries on macOS;
- Uploads all release artifacts to a new GitHub Release;
- A new git tag `vX.Y.Z` is created in the remote repository;
- The changelog is [generated from the list of merged pull requests](https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes);
- Updates cli.github.com with the contents of the new release;
- Updates our Homebrew formula in the homebrew-core repo.
To test out the build system while avoiding creating an actual release:
```sh
script/release --staging vX.Y.Z --branch patch-1 -p macos
```
The build artifacts will be available via `gh run download <RUN> -n macos`.
## General guidelines
* Features to be released should be reviewed and approved at least one day prior to the release.
* Feature releases should bump up the minor version number.
* Breaking releases should bump up the major version number. These should generally be rare.
## Tagging a new release
1. `git tag v1.2.3 && git push origin v1.2.3`
2. Wait several minutes for builds to run: <https://github.com/cli/cli/actions>
3. Verify release is displayed and has correct assets: <https://github.com/cli/cli/releases>
4. Scan generated release notes and optionally add a human touch by grouping items under topic sections
5. Verify the marketing site was updated: <https://cli.github.com>
6. (Optional) Delete any pre-releases related to this release
A successful build will result in changes across several repositories:
* <https://github.com/github/cli.github.com>
* <https://github.com/Homebrew/homebrew-core/pulls>
* <https://github.com/cli/scoop-gh>
If the build fails, there is not a clean way to re-run it. The easiest way would be to start over by deleting the partial release on GitHub and re-publishing the tag. Note that this might be disruptive to users or tooling that were already notified about an upgrade. If a functional release and its binaries are already out there, it might be better to try to manually fix up only the specific workflow tasks that failed. Use your best judgement depending on the failure type.
## Release locally for debugging
## Test the build system locally
A local release can be created for testing without creating anything official on the release page.
1. Make sure GoReleaser is installed: `brew install goreleaser`
2. `goreleaser --skip-validate --skip-publish --rm-dist`
2. `script/release --local`
3. Find the built products under `dist/`.

View file

@ -16,7 +16,10 @@ To be considered triaged, `enhancement` issues require at least one of the follo
- `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`
To be considered triaged, `bug` issues require a severity label: one of `p1`, `p2`, or `p3`, which are defined as follows:
- `p1`: Affects a large population and inhibits work
- `p2`: Affects more than a few users but doesn't prevent core functions
- `p3`: Affects a small number of users or is largely cosmetic
## Expectations for community pull requests

View file

@ -36,6 +36,19 @@ type Client struct {
mu sync.Mutex
}
func (c *Client) Copy() *Client {
return &Client{
GhPath: c.GhPath,
RepoDir: c.RepoDir,
GitPath: c.GitPath,
Stderr: c.Stderr,
Stdin: c.Stdin,
Stdout: c.Stdout,
commandContext: c.commandContext,
}
}
func (c *Client) Command(ctx context.Context, args ...string) (*Command, error) {
if c.RepoDir != "" {
args = append([]string{"-C", c.RepoDir}, args...)
@ -408,6 +421,44 @@ func (c *Client) revParse(ctx context.Context, args ...string) ([]byte, error) {
return cmd.Output()
}
func (c *Client) IsLocalGitRepo(ctx context.Context) (bool, error) {
_, err := c.GitDir(ctx)
if err != nil {
var execError errWithExitCode
if errors.As(err, &execError) && execError.ExitCode() == 128 {
return false, nil
}
return false, err
}
return true, nil
}
func (c *Client) UnsetRemoteResolution(ctx context.Context, name string) error {
args := []string{"config", "--unset", fmt.Sprintf("remote.%s.gh-resolved", name)}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func (c *Client) SetRemoteBranches(ctx context.Context, remote string, refspec string) error {
args := []string{"remote", "set-branches", remote, refspec}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
// 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 {
@ -513,31 +564,6 @@ func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBra
return remote, nil
}
func (c *Client) IsLocalGitRepo(ctx context.Context) (bool, error) {
_, err := c.GitDir(ctx)
if err != nil {
var execError errWithExitCode
if errors.As(err, &execError) && execError.ExitCode() == 128 {
return false, nil
}
return false, err
}
return true, nil
}
func (c *Client) UnsetRemoteResolution(ctx context.Context, name string) error {
args := []string{"config", "--unset", fmt.Sprintf("remote.%s.gh-resolved", name)}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func resolveGitPath() (string, error) {
path, err := safeexec.LookPath("git")
if err != nil {

View file

@ -303,7 +303,7 @@ func TestClientCurrentBranch(t *testing.T) {
wantBranch: "branch\u00A0with\u00A0non\u00A0breaking\u00A0space",
},
{
name: "detatched head",
name: "detached head",
cmdExitStatus: 1,
wantCmdArgs: `path/to/git symbolic-ref --quiet HEAD`,
wantErrorMsg: "failed to run git: not on any branch",
@ -339,7 +339,7 @@ func TestClientShowRefs(t *testing.T) {
wantErrorMsg string
}{
{
name: "show refs with one vaid ref and one invalid ref",
name: "show refs with one valid ref and one invalid ref",
cmdExitStatus: 128,
cmdStdout: "9ea76237a557015e73446d33268569a114c0649c refs/heads/valid",
cmdStderr: "fatal: 'refs/heads/invalid' - not a valid ref",
@ -834,6 +834,84 @@ func TestClientPathFromRoot(t *testing.T) {
}
}
func TestClientUnsetRemoteResolution(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
}{
{
name: "unset remote resolution",
wantCmdArgs: `path/to/git config --unset remote.origin.gh-resolved`,
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git config --unset remote.origin.gh-resolved`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.UnsetRemoteResolution(context.Background(), "origin")
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
})
}
}
func TestClientSetRemoteBranches(t *testing.T) {
tests := []struct {
name string
cmdExitStatus int
cmdStdout string
cmdStderr string
wantCmdArgs string
wantErrorMsg string
}{
{
name: "set remote branches",
wantCmdArgs: `path/to/git remote set-branches origin trunk`,
},
{
name: "git error",
cmdExitStatus: 1,
cmdStderr: "git error message",
wantCmdArgs: `path/to/git remote set-branches origin trunk`,
wantErrorMsg: "failed to run git: git error message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd, cmdCtx := createCommandContext(t, tt.cmdExitStatus, tt.cmdStdout, tt.cmdStderr)
client := Client{
GitPath: "path/to/git",
commandContext: cmdCtx,
}
err := client.SetRemoteBranches(context.Background(), "origin", "trunk")
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
if tt.wantErrorMsg == "" {
assert.NoError(t, err)
} else {
assert.EqualError(t, err, tt.wantErrorMsg)
}
})
}
}
func TestClientFetch(t *testing.T) {
tests := []struct {
name string

50
go.mod
View file

@ -3,13 +3,13 @@ module github.com/cli/cli/v2
go 1.19
require (
github.com/AlecAivazis/survey/v2 v2.3.6
github.com/AlecAivazis/survey/v2 v2.3.7
github.com/MakeNowJust/heredoc v1.0.0
github.com/briandowns/spinner v1.18.1
github.com/cenkalti/backoff/v4 v4.2.0
github.com/cenkalti/backoff/v4 v4.2.1
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da
github.com/charmbracelet/lipgloss v0.5.0
github.com/cli/go-gh v1.2.1
github.com/cli/go-gh/v2 v2.0.1
github.com/cli/oauth v1.0.1
github.com/cli/safeexec v1.0.1
github.com/cpuguy83/go-md2man/v2 v2.0.2
@ -21,27 +21,28 @@ require (
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/henvic/httpretty v0.1.2
github.com/joho/godotenv v1.5.1
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-colorable v0.1.13
github.com/mattn/go-isatty v0.0.18
github.com/mattn/go-isatty v0.0.19
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38
github.com/opentracing/opentracing-go v1.1.0
github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d
github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00
github.com/shurcooL/githubv4 v0.0.0-20230424031643-6cea62ecd5a9
github.com/sourcegraph/jsonrpc2 v0.1.0
github.com/spf13/cobra v1.6.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.7.5
github.com/zalando/go-keyring v0.2.2
github.com/stretchr/testify v1.8.4
github.com/zalando/go-keyring v0.2.3
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519
golang.org/x/sync v0.1.0
golang.org/x/term v0.6.0
golang.org/x/text v0.8.0
google.golang.org/grpc v1.49.0
google.golang.org/protobuf v1.27.1
golang.org/x/term v0.7.0
golang.org/x/text v0.9.0
google.golang.org/grpc v1.53.0
google.golang.org/protobuf v1.28.1
gopkg.in/h2non/gock.v1 v1.1.2
gopkg.in/yaml.v3 v3.0.1
)
@ -49,9 +50,9 @@ require (
github.com/alecthomas/chroma v0.10.0 // indirect
github.com/alessio/shellescape v1.4.1 // 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/danieljoos/wincred v1.1.2 // indirect
github.com/cli/browser v1.2.0 // indirect
github.com/cli/shurcooL-graphql v0.0.3 // indirect
github.com/danieljoos/wincred v1.2.0 // 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
@ -59,10 +60,12 @@ require (
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.1 // indirect
github.com/itchyny/gojq v0.12.8 // indirect
github.com/itchyny/timefmt-go v0.1.3 // indirect
github.com/itchyny/gojq v0.12.13 // indirect
github.com/itchyny/timefmt-go v0.1.5 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/microcosm-cc/bluemonday v1.0.20 // indirect
@ -70,20 +73,17 @@ require (
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/rivo/uniseg v0.4.4 // 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/stretchr/objx v0.5.0 // indirect
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect
github.com/yuin/goldmark v1.4.13 // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect
golang.org/x/sys v0.6.0 // indirect
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 // indirect
golang.org/x/net v0.9.0 // indirect
golang.org/x/sys v0.8.0 // indirect
google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // 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
replace github.com/henvic/httpretty v0.0.6 => github.com/mislav/httpretty v0.1.1-0.20230202151216-d31343e0d884

441
go.sum
View file

@ -1,40 +1,5 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
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/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=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
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/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=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
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.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/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
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-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
@ -47,48 +12,39 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
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.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4=
github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
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/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/browser v1.2.0 h1:yvU7e9qf97kZqGFX6n2zJPHsmSObY9ske+iCvKelvXg=
github.com/cli/browser v1.2.0/go.mod h1:xFFnXLVcAyW9ni0cuo6NnrbCP75JxJ0RO7VtCBiH/oI=
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.2.1 h1:xFrjejSsgPiwXFP6VYynKWwxLQcNJy3Twbu82ZDlR/o=
github.com/cli/go-gh v1.2.1/go.mod h1:Jxk8X+TCO4Ui/GarwY9tByWm/8zp4jJktzVZNlTW5VM=
github.com/cli/go-gh/v2 v2.0.1 h1:W4L7C5xT9CwkcqTsUzBhFhRKGpek9oRtdDLku2Hku+E=
github.com/cli/go-gh/v2 v2.0.1/go.mod h1:zWab1jRnJ0Ug8qRjsZHFk/Oq51ZWuhSxRL2FDUDgQWk=
github.com/cli/oauth v1.0.1 h1:pXnTFl/qUegXHK531Dv0LNjW4mLx626eS42gnzfXJPA=
github.com/cli/oauth v1.0.1/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
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/cli/shurcooL-graphql v0.0.3 h1:CtpPxyGDs136/+ZeyAfUKYmcQBjDlq5aqnrDCW5Ghh8=
github.com/cli/shurcooL-graphql v0.0.3/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA=
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.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0=
github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0=
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
@ -97,100 +53,46 @@ github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdk
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/k=
github.com/gdamore/tcell/v2 v2.5.4/go.mod h1:dZgRy5v4iMobMEcWNYBtREnDZAT9DYmfqIkrgEMxLyw=
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/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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/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=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
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/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=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
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/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=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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.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.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/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=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
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/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/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/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/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
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-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-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/henvic/httpretty v0.1.2 h1:EQo556sO0xeXAjP10eB+BZARMuvkdGqtfeS4Ntjvkiw=
github.com/henvic/httpretty v0.1.2/go.mod h1:ViEsly7wgdugYtymX54pYp6Vv2wqZmNHayJ6q8tlKCc=
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/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc=
github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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/itchyny/gojq v0.12.13 h1:IxyYlHYIlspQHHTE0f3cJF0NKDMfajxViuhBLnHd/QU=
github.com/itchyny/gojq v0.12.13/go.mod h1:JzwzAqenfhrPUuwbmEz3nu3JQmFLlQTQMUcOdnu/Sf4=
github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm6GE=
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
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/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/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
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/pty v1.1.1/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/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
@ -199,8 +101,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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=
@ -213,8 +115,6 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyex
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/mislav/httpretty v0.1.1-0.20230202151216-d31343e0d884 h1:JQp1j1IWuMQZc2tyDQ9KmksjQbw5MhUOzWzZZn7WyU0=
github.com/mislav/httpretty v0.1.1-0.20230202151216-d31343e0d884/go.mod h1:ViEsly7wgdugYtymX54pYp6Vv2wqZmNHayJ6q8tlKCc=
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=
@ -224,24 +124,24 @@ 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/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4=
github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
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/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
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/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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/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/githubv4 v0.0.0-20230424031643-6cea62ecd5a9 h1:nCBaIs5/R0HFP5+aPW/SzFUF8z0oKuCXmuDmHWaxzjY=
github.com/shurcooL/githubv4 v0.0.0-20230424031643-6cea62ecd5a9/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=
@ -251,19 +151,17 @@ github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUq
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
@ -271,198 +169,55 @@ github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
github.com/yuin/goldmark-emoji v1.0.1/go.mod h1:2w1E6FEWLcDQkoTE+7HU6QF1F6SLlNGjRIBbIZQFqkQ=
github.com/zalando/go-keyring v0.2.2 h1:f0xmpYiSrHtSNAVgwip93Cg8tuF45HJM6rHq/A5RI/4=
github.com/zalando/go-keyring v0.2.2/go.mod h1:sI3evg9Wvpw3+n4SqplGSJUMwtDeROfD4nsFz4z9PG0=
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=
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=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
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/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=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
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=
github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms=
github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
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-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-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-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=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
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-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=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/oauth2 v0.4.0 h1:NF0gk8LVPg1Ml7SSbGyySuoxdsXitj7TvgvuRxIMc/M=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
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-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/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-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-20191001151750-bb3f8db39f24/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-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/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-20210819135213-f52c844e1c1c/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-20211019181941-9d821ace8654/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-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-20220722155257-8c9f86f7a55f/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/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/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/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/term v0.7.0 h1:BEvjmm5fURWqcfbSKTdpkDXYBrUS1c0m8agp14W48vQ=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
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.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
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-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=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
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-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.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
@ -470,104 +225,20 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
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/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=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
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/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/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=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
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-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/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=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
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.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=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f h1:BWUVssLB0HVOSY78gIdvk1dTVYtT1y8SBWtPYuTJ/6w=
google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM=
google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc=
google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
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/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/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

View file

@ -3,7 +3,7 @@ package browser
import (
"io"
ghBrowser "github.com/cli/go-gh/pkg/browser"
ghBrowser "github.com/cli/go-gh/v2/pkg/browser"
)
type Browser interface {
@ -12,5 +12,5 @@ type Browser interface {
func New(launcher string, stdout, stderr io.Writer) Browser {
b := ghBrowser.New(launcher, stdout, stderr)
return &b
return b
}

View file

@ -1,6 +1,7 @@
package build
import (
"os"
"runtime/debug"
)
@ -16,4 +17,11 @@ func init() {
Version = info.Main.Version
}
}
// Signal the tcell library to skip its expensive `init` block. This saves 30-40ms in startup
// time for the gh process. The downside is that some Unicode glyphs from user-generated
// content might cause mis-alignment in tcell-enabled views.
//
// https://github.com/gdamore/tcell/commit/2f889d79bd61b1fd2f43372529975a65b792a7ae
_ = os.Setenv("TCELL_MINIMIZE", "1")
}

View file

@ -122,17 +122,24 @@ func (a *API) GetUser(ctx context.Context) (*User, error) {
var response User
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
return &response, nil
}
// RepositoryOwner represents owner of a repository
type RepositoryOwner struct {
Type string `json:"type"`
Login string `json:"login"`
}
// Repository represents a GitHub repository.
type Repository struct {
ID int `json:"id"`
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
ID int `json:"id"`
FullName string `json:"full_name"`
DefaultBranch string `json:"default_branch"`
Owner RepositoryOwner `json:"owner"`
}
// GetRepository returns the repository associated with the given owner and name.
@ -160,7 +167,7 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error
var response Repository
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
return &response, nil
@ -185,6 +192,15 @@ type Codespace struct {
PendingOperationDisabledReason string `json:"pending_operation_disabled_reason"`
IdleTimeoutNotice string `json:"idle_timeout_notice"`
WebURL string `json:"web_url"`
DevContainerPath string `json:"devcontainer_path"`
Prebuild bool `json:"prebuild"`
Location string `json:"location"`
IdleTimeoutMinutes int `json:"idle_timeout_minutes"`
RetentionPeriodMinutes int `json:"retention_period_minutes"`
RetentionExpiresAt string `json:"retention_expires_at"`
RecentFolders []string `json:"recent_folders"`
BillableOwner User `json:"billable_owner"`
EnvironmentId string `json:"environment_id"`
}
type CodespaceGitStatus struct {
@ -223,8 +239,8 @@ type CodespaceConnection struct {
HostPublicKeys []string `json:"hostPublicKeys"`
}
// CodespaceFields is the list of exportable fields for a codespace.
var CodespaceFields = []string{
// ListCodespaceFields is the list of exportable fields for a codespace when using the `gh cs list` command.
var ListCodespaceFields = []string{
"displayName",
"name",
"owner",
@ -237,6 +253,30 @@ var CodespaceFields = []string{
"vscsTarget",
}
// ViewCodespaceFields is the list of exportable fields for a codespace when using the `gh cs view` command.
var ViewCodespaceFields = []string{
"name",
"displayName",
"state",
"owner",
"billableOwner",
"location",
"repository",
"gitStatus",
"devcontainerPath",
"machineName",
"machineDisplayName",
"prebuild",
"createdAt",
"lastUsedAt",
"idleTimeoutMinutes",
"retentionPeriodDays",
"retentionExpiresAt",
"recentFolders",
"vscsTarget",
"environmentId",
}
func (c *Codespace) ExportData(fields []string) map[string]interface{} {
v := reflect.ValueOf(c).Elem()
data := map[string]interface{}{}
@ -249,11 +289,17 @@ func (c *Codespace) ExportData(fields []string) map[string]interface{} {
data[f] = c.Repository.FullName
case "machineName":
data[f] = c.Machine.Name
case "machineDisplayName":
data[f] = c.Machine.DisplayName
case "retentionPeriodDays":
data[f] = c.RetentionPeriodMinutes / 1440
case "gitStatus":
data[f] = map[string]interface{}{
"ref": c.GitStatus.Ref,
"hasUnpushedChanges": c.GitStatus.HasUnpushedChanges,
"hasUncommittedChanges": c.GitStatus.HasUncommittedChanges,
"ahead": c.GitStatus.Ahead,
"behind": c.GitStatus.Behind,
}
case "vscsTarget":
if c.VSCSTarget != "" && c.VSCSTarget != VSCSTargetProduction {
@ -336,7 +382,7 @@ func (a *API) ListCodespaces(ctx context.Context, opts ListCodespacesOptions) (c
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
nextURL := findNextPage(resp.Header.Get("Link"))
@ -398,7 +444,7 @@ func (a *API) GetOrgMemberCodespace(ctx context.Context, orgName string, userNam
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
for _, cs := range response.Codespaces {
@ -455,7 +501,7 @@ func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeCon
var response Codespace
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
return &response, nil
@ -563,7 +609,7 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc
Machines []*Machine `json:"machines"`
}
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
return response.Machines, nil
@ -636,7 +682,7 @@ func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch str
Items []*Repository `json:"items"`
}
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
repoNames := make([]string, len(response.Items))
@ -683,7 +729,7 @@ func (a *API) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*User,
}
}
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
// While this response contains further helpful information ahead of codespace creation,
@ -815,7 +861,7 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*
var response Codespace
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
return &response, errProvisioningInProgress // RPC finished before result of creation known
@ -831,7 +877,7 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*
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)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
if ue.AllowPermissionsURL != "" {
@ -853,7 +899,7 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*
var response Codespace
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
return &response, nil
@ -934,7 +980,7 @@ func (a *API) ListDevContainers(ctx context.Context, repoID int, branch string,
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
nextURL := findNextPage(resp.Header.Get("Link"))
@ -1007,7 +1053,7 @@ func (a *API) EditCodespace(ctx context.Context, codespaceName string, params *E
var response Codespace
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
return &response, nil
@ -1055,7 +1101,7 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod
var response getCodespaceRepositoryContentsResponse
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
return nil, fmt.Errorf("error unmarshalling response: %w", err)
}
decoded, err := base64.StdEncoding.DecodeString(response.Content)

View file

@ -21,7 +21,7 @@ func generateCodespaceList(start int, end int) []*Codespace {
return codespacesList
}
func createFakeListEndpointServer(t *testing.T, initalTotal int, finalTotal int) *httptest.Server {
func createFakeListEndpointServer(t *testing.T, initialTotal int, finalTotal int) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user/codespaces" {
t.Fatal("Incorrect path")
@ -48,7 +48,7 @@ func createFakeListEndpointServer(t *testing.T, initalTotal int, finalTotal int)
switch page {
case 1:
response.Codespaces = generateCodespaceList(0, per_page)
response.TotalCount = initalTotal
response.TotalCount = initialTotal
w.Header().Set("Link", fmt.Sprintf(`<http://%[1]s/user/codespaces?page=3&per_page=%[2]d>; rel="last", <http://%[1]s/user/codespaces?page=2&per_page=%[2]d>; rel="next"`, r.Host, per_page))
case 2:
response.Codespaces = generateCodespaceList(per_page, per_page*2)

View file

@ -88,9 +88,14 @@ func ConnectToLiveshare(ctx context.Context, progress progressIndicator, session
})
}
// ListenTCP starts a localhost tcp listener and returns the listener and bound port
func ListenTCP(port int) (*net.TCPListener, int, error) {
addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("127.0.0.1:%d", port))
// ListenTCP starts a localhost tcp listener on 127.0.0.1 (unless allInterfaces is true) and returns the listener and bound port
func ListenTCP(port int, allInterfaces bool) (*net.TCPListener, int, error) {
host := "127.0.0.1"
if allInterfaces {
host = ""
}
addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", host, port))
if err != nil {
return nil, 0, fmt.Errorf("failed to build tcp address: %w", err)
}

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.28.1
// protoc v3.21.12
// protoc v3.12.4
// source: codespace/codespace_host_service.v1.proto
package codespace

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.2.0
// - protoc v3.21.12
// - protoc v3.12.4
// source: codespace/codespace_host_service.v1.proto
package codespace

View file

@ -20,11 +20,11 @@ function generate {
local contract="$dir/$proto"
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative "$contract"
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative "$contract" --experimental_allow_proto3_optional
echo "Generated protocol buffers for $contract"
services=$(cat "$contract" | grep -Eo "service .+ {" | awk '{print $2 "Server"}')
moq -out $contract.mock.go $dir $services
services=$(grep -Eo "service .+ {" <$contract | awk '{print $2 "Server"}')
moq -out "$contract.mock.go" "$dir" "$services"
echo "Generated mock protocols for $contract"
}

View file

@ -239,6 +239,7 @@ func (i *invoker) StartSSHServerWithOptions(ctx context.Context, options StartSS
}
func listenTCP() (*net.TCPListener, error) {
// We will end up using this same address to connect, so specify the IP also or the connect will fail
addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0")
if err != nil {
return nil, fmt.Errorf("failed to build tcp address: %w", err)

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.28.1
// protoc v3.21.12
// protoc v3.12.4
// source: jupyter/jupyter_server_host_service.v1.proto
package jupyter

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.2.0
// - protoc v3.21.12
// - protoc v3.12.4
// source: jupyter/jupyter_server_host_service.v1.proto
package jupyter

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.28.1
// protoc v3.21.12
// protoc v3.12.4
// source: ssh/ssh_server_host_service.v1.proto
package ssh

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.2.0
// - protoc v3.21.12
// - protoc v3.12.4
// source: ssh/ssh_server_host_service.v1.proto
package ssh

View file

@ -18,13 +18,15 @@ type printer interface {
// Shell runs an interactive secure shell over an existing
// port-forwarding session. It runs until the shell is terminated
// (including by cancellation of the context).
func Shell(ctx context.Context, p printer, sshArgs []string, port int, destination string, usingCustomPort bool) error {
func Shell(
ctx context.Context, p printer, sshArgs []string, port int, destination string, printConnDetails bool,
) error {
cmd, connArgs, err := newSSHCommand(ctx, port, destination, sshArgs)
if err != nil {
return fmt.Errorf("failed to create ssh command: %w", err)
}
if usingCustomPort {
if printConnDetails {
p.Printf("Connection Details: ssh %s %s", destination, connArgs)
}

View file

@ -52,7 +52,7 @@ func PollPostCreateStates(ctx context.Context, progress progressIndicator, apiCl
}()
// Ensure local port is listening before client (getPostCreateOutput) connects.
listen, localPort, err := ListenTCP(0)
listen, localPort, err := ListenTCP(0, false)
if err != nil {
return err
}

View file

@ -4,9 +4,9 @@ import (
"os"
"path/filepath"
ghAuth "github.com/cli/go-gh/pkg/auth"
ghConfig "github.com/cli/go-gh/pkg/config"
"github.com/zalando/go-keyring"
"github.com/cli/cli/v2/internal/keyring"
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
ghConfig "github.com/cli/go-gh/v2/pkg/config"
)
const (
@ -124,7 +124,7 @@ type AuthConfig struct {
// Token will retrieve the auth token for the given hostname,
// searching environment variables, plain text config, and
// lastly encypted storage.
// lastly encrypted storage.
func (c *AuthConfig) Token(hostname string) (string, string) {
if c.tokenOverride != nil {
return c.tokenOverride(hostname)

View file

@ -6,7 +6,7 @@ import (
"path/filepath"
"testing"
ghConfig "github.com/cli/go-gh/pkg/config"
ghConfig "github.com/cli/go-gh/v2/pkg/config"
)
func NewBlankConfig() *ConfigMock {

View file

@ -22,30 +22,25 @@ var allIssueFeatures = IssueFeatures{
}
type PullRequestFeatures struct {
ReviewDecision bool
StatusCheckRollup bool
BranchProtectionRule bool
MergeQueue bool
MergeQueue bool
// CheckRunAndStatusContextCounts indicates whether the API supports
// the checkRunCount, checkRunCountsByState, statusContextCount and stausContextCountsByState
// fields on the StatusCheckRollupContextConnection
CheckRunAndStatusContextCounts bool
}
var allPullRequestFeatures = PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: true,
MergeQueue: true,
CheckRunAndStatusContextCounts: 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,
@ -103,32 +98,40 @@ func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) {
// return allPullRequestFeatures, nil
// }
features := PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
}
var featureDetection struct {
var pullRequestFeatureDetection struct {
PullRequest struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"PullRequest: __type(name: \"PullRequest\")"`
StatusCheckRollupContextConnection struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"StatusCheckRollupContextConnection: __type(name: \"StatusCheckRollupContextConnection\")"`
}
gql := api.NewClientFromHTTP(d.httpClient)
err := gql.Query(d.host, "PullRequest_fields", &featureDetection, nil)
if err != nil {
return features, err
if err := gql.Query(d.host, "PullRequest_fields", &pullRequestFeatureDetection, nil); err != nil {
return PullRequestFeatures{}, err
}
for _, field := range featureDetection.PullRequest.Fields {
features := PullRequestFeatures{}
for _, field := range pullRequestFeatureDetection.PullRequest.Fields {
if field.Name == "isInMergeQueue" {
features.MergeQueue = true
}
}
for _, field := range pullRequestFeatureDetection.StatusCheckRollupContextConnection.Fields {
// We only check for checkRunCount here but it, checkRunCountsByState, statusContextCount and statusContextCountsByState
// were all introduced in the same version of the API.
if field.Name == "checkRunCount" {
features.CheckRunAndStatusContextCounts = true
}
}
return features, nil
}
@ -137,10 +140,7 @@ func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) {
return allRepositoryFeatures, nil
}
features := RepositoryFeatures{
IssueTemplateQuery: true,
IssueTemplateMutation: true,
}
features := RepositoryFeatures{}
var featureDetection struct {
Repository struct {

View file

@ -82,21 +82,32 @@ func TestPullRequestFeatures(t *testing.T) {
wantErr bool
}{
{
name: "github.com",
name: "github.com with merge queue and status check counts by state",
hostname: "github.com",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{ "data": { "PullRequest": { "fields": [
{"name": "isInMergeQueue"},
{"name": "isMergeQueueEnabled"}
] } } }
`),
{
"data": {
"PullRequest": {
"fields": [
{"name": "isInMergeQueue"},
{"name": "isMergeQueueEnabled"}
]
},
"StatusCheckRollupContextConnection": {
"fields": [
{"name": "checkRunCount"},
{"name": "checkRunCountsByState"},
{"name": "statusContextCount"},
{"name": "statusContextCountsByState"}
]
}
}
}`),
},
wantFeatures: PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: true,
MergeQueue: true,
CheckRunAndStatusContextCounts: true,
},
wantErr: false,
},
@ -105,34 +116,77 @@ func TestPullRequestFeatures(t *testing.T) {
hostname: "github.com",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{ "data": { "PullRequest": { "fields": [
] } } }
`),
{
"data": {
"PullRequest": {
"fields": []
},
"StatusCheckRollupContextConnection": {
"fields": [
{"name": "checkRunCount"},
{"name": "checkRunCountsByState"},
{"name": "statusContextCount"},
{"name": "statusContextCountsByState"}
]
}
}
}`),
},
wantFeatures: PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: false,
MergeQueue: false,
CheckRunAndStatusContextCounts: true,
},
wantErr: false,
},
{
name: "GHE",
name: "GHE with merge queue and status check counts by state",
hostname: "git.my.org",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{ "data": { "PullRequest": { "fields": [
{"name": "isInMergeQueue"},
{"name": "isMergeQueueEnabled"}
] } } }
`),
{
"data": {
"PullRequest": {
"fields": [
{"name": "isInMergeQueue"},
{"name": "isMergeQueueEnabled"}
]
},
"StatusCheckRollupContextConnection": {
"fields": [
{"name": "checkRunCount"},
{"name": "checkRunCountsByState"},
{"name": "statusContextCount"},
{"name": "statusContextCountsByState"}
]
}
}
}`),
},
wantFeatures: PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: true,
MergeQueue: true,
CheckRunAndStatusContextCounts: true,
},
wantErr: false,
},
{
name: "GHE without merge queue and status check counts by state",
hostname: "git.my.org",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{
"data": {
"PullRequest": {
"fields": []
},
"StatusCheckRollupContextConnection": {
"fields": []
}
}
}`),
},
wantFeatures: PullRequestFeatures{
MergeQueue: false,
CheckRunAndStatusContextCounts: false,
},
wantErr: false,
},
@ -169,8 +223,6 @@ func TestRepositoryFeatures(t *testing.T) {
name: "github.com",
hostname: "github.com",
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
PullRequestTemplateQuery: true,
VisibilityField: true,
AutoMerge: true,
@ -184,8 +236,6 @@ func TestRepositoryFeatures(t *testing.T) {
`query Repository_fields\b`: `{"data": {}}`,
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
PullRequestTemplateQuery: false,
},
wantErr: false,
@ -201,8 +251,6 @@ func TestRepositoryFeatures(t *testing.T) {
`),
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
PullRequestTemplateQuery: true,
},
wantErr: false,
@ -218,9 +266,7 @@ func TestRepositoryFeatures(t *testing.T) {
`),
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
VisibilityField: true,
VisibilityField: true,
},
wantErr: false,
},
@ -235,9 +281,7 @@ func TestRepositoryFeatures(t *testing.T) {
`),
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
AutoMerge: true,
AutoMerge: true,
},
wantErr: false,
},

View file

@ -6,37 +6,56 @@ import (
"strings"
)
// DefaultHostname is the domain name of the default GitHub instance.
const defaultHostname = "github.com"
// localhost is the domain name of a local GitHub instance
// Localhost is the domain name of a local GitHub instance.
const localhost = "github.localhost"
// Default returns the host name of the default GitHub instance
// TenancyHost is the domain name of a tenancy GitHub instance.
const tenancyHost = "ghe.com"
// Default returns the host name of the default GitHub instance.
func Default() string {
return defaultHostname
}
// IsEnterprise reports whether a non-normalized host name looks like a GHE instance
// IsEnterprise reports whether a non-normalized host name looks like a GHE instance.
func IsEnterprise(h string) bool {
normalizedHostName := NormalizeHostname(h)
return normalizedHostName != defaultHostname && normalizedHostName != localhost
}
// IsTenancy reports whether a non-normalized host name looks like a tenancy instance.
func IsTenancy(h string) bool {
normalizedHostName := NormalizeHostname(h)
return strings.HasSuffix(normalizedHostName, "."+tenancyHost)
}
// TenantName extracts the tenant name from tenancy host name and
// reports whether it found the tenant name.
func TenantName(h string) (string, bool) {
normalizedHostName := NormalizeHostname(h)
return cutSuffix(normalizedHostName, "."+tenancyHost)
}
func isGarage(h string) bool {
return strings.EqualFold(h, "garage.github.com")
}
// NormalizeHostname returns the canonical host name of a GitHub instance
// NormalizeHostname returns the canonical host name of a GitHub instance.
func NormalizeHostname(h string) string {
hostname := strings.ToLower(h)
if strings.HasSuffix(hostname, "."+defaultHostname) {
return defaultHostname
}
if strings.HasSuffix(hostname, "."+localhost) {
return localhost
}
if before, found := cutSuffix(hostname, "."+tenancyHost); found {
idx := strings.LastIndex(before, ".")
return fmt.Sprintf("%s.%s", before[idx+1:], tenancyHost)
}
return hostname
}
@ -78,11 +97,9 @@ func RESTPrefix(hostname string) string {
func GistPrefix(hostname string) string {
prefix := "https://"
if strings.EqualFold(hostname, localhost) {
prefix = "http://"
}
return prefix + GistHost(hostname)
}
@ -105,3 +122,11 @@ func HostPrefix(hostname string) string {
}
return fmt.Sprintf("https://%s/", hostname)
}
// Backport strings.CutSuffix from Go 1.20.
func cutSuffix(s, suffix string) (string, bool) {
if !strings.HasSuffix(s, suffix) {
return s, false
}
return s[:len(s)-len(suffix)], true
}

View file

@ -49,6 +49,87 @@ func TestIsEnterprise(t *testing.T) {
}
}
func TestIsTenancy(t *testing.T) {
tests := []struct {
host string
want bool
}{
{
host: "github.com",
want: false,
},
{
host: "github.localhost",
want: false,
},
{
host: "garage.github.com",
want: false,
},
{
host: "ghe.com",
want: false,
},
{
host: "tenant.ghe.com",
want: true,
},
{
host: "api.tenant.ghe.com",
want: true,
},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
if got := IsTenancy(tt.host); got != tt.want {
t.Errorf("IsTenancy() = %v, want %v", got, tt.want)
}
})
}
}
func TestTenantName(t *testing.T) {
tests := []struct {
host string
wantTenant string
wantFound bool
}{
{
host: "github.com",
wantTenant: "github.com",
},
{
host: "github.localhost",
wantTenant: "github.localhost",
},
{
host: "garage.github.com",
wantTenant: "github.com",
},
{
host: "ghe.com",
wantTenant: "ghe.com",
},
{
host: "tenant.ghe.com",
wantTenant: "tenant",
wantFound: true,
},
{
host: "api.tenant.ghe.com",
wantTenant: "tenant",
wantFound: true,
},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
if tenant, found := TenantName(tt.host); tenant != tt.wantTenant || found != tt.wantFound {
t.Errorf("TenantName(%v) = %v %v, want %v %v", tt.host, tenant, found, tt.wantTenant, tt.wantFound)
}
})
}
}
func TestNormalizeHostname(t *testing.T) {
tests := []struct {
host string
@ -90,6 +171,18 @@ func TestNormalizeHostname(t *testing.T) {
host: "git.my.org",
want: "git.my.org",
},
{
host: "ghe.com",
want: "ghe.com",
},
{
host: "tenant.ghe.com",
want: "tenant.ghe.com",
},
{
host: "api.tenant.ghe.com",
want: "tenant.ghe.com",
},
}
for _, tt := range tests {
t.Run(tt.host, func(t *testing.T) {
@ -139,6 +232,7 @@ func TestHostnameValidator(t *testing.T) {
})
}
}
func TestGraphQLEndpoint(t *testing.T) {
tests := []struct {
host string

View file

@ -6,8 +6,8 @@ import (
"strings"
"github.com/cli/cli/v2/internal/ghinstance"
ghAuth "github.com/cli/go-gh/pkg/auth"
"github.com/cli/go-gh/pkg/repository"
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
"github.com/cli/go-gh/v2/pkg/repository"
)
// Interface describes an object that represents a GitHub repository
@ -54,7 +54,7 @@ func FromFullNameWithHost(nwo, fallbackHost string) (Interface, error) {
if err != nil {
return nil, err
}
return NewWithHost(repo.Owner(), repo.Name(), repo.Host()), nil
return NewWithHost(repo.Owner, repo.Name, repo.Host), nil
}
// FromURL extracts the GitHub repository information from a git remote URL
@ -92,12 +92,13 @@ func GenerateRepoURL(repo Interface, p string, args ...interface{}) string {
return baseURL
}
// TODO there is a parallel implementation for non-isolated commands
func FormatRemoteURL(repo Interface, protocol string) string {
if protocol == "ssh" {
if tenant, found := ghinstance.TenantName(repo.RepoHost()); found {
return fmt.Sprintf("%s@%s:%s/%s.git", tenant, repo.RepoHost(), repo.RepoOwner(), repo.RepoName())
}
return fmt.Sprintf("git@%s:%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName())
}
return fmt.Sprintf("%s%s/%s.git", ghinstance.HostPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName())
}

View file

@ -220,3 +220,59 @@ func TestFromFullName(t *testing.T) {
})
}
}
func TestFormatRemoteURL(t *testing.T) {
tests := []struct {
name string
repoHost string
repoOwner string
repoName string
protocol string
want string
}{
{
name: "https protocol",
repoHost: "github.com",
repoOwner: "owner",
repoName: "name",
protocol: "https",
want: "https://github.com/owner/name.git",
},
{
name: "https protocol local host",
repoHost: "github.localhost",
repoOwner: "owner",
repoName: "name",
protocol: "https",
want: "http://github.localhost/owner/name.git",
},
{
name: "ssh protocol",
repoHost: "github.com",
repoOwner: "owner",
repoName: "name",
protocol: "ssh",
want: "git@github.com:owner/name.git",
},
{
name: "ssh protocol tenancy host",
repoHost: "tenant.ghe.com",
repoOwner: "owner",
repoName: "name",
protocol: "ssh",
want: "tenant@tenant.ghe.com:owner/name.git",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := ghRepo{
hostname: tt.repoHost,
owner: tt.repoOwner,
name: tt.repoName,
}
if url := FormatRemoteURL(r, tt.protocol); url != tt.want {
t.Errorf("expected url %q, got %q", tt.want, url)
}
})
}
}

View file

@ -0,0 +1,68 @@
// Package keyring is a simple wrapper that adds timeouts to the zalando/go-keyring package.
package keyring
import (
"time"
"github.com/zalando/go-keyring"
)
type TimeoutError struct {
message string
}
func (e *TimeoutError) Error() string {
return e.message
}
// Set secret in keyring for user.
func Set(service, user, secret string) error {
ch := make(chan error, 1)
go func() {
defer close(ch)
ch <- keyring.Set(service, user, secret)
}()
select {
case err := <-ch:
return err
case <-time.After(3 * time.Second):
return &TimeoutError{"timeout while trying to set secret in keyring"}
}
}
// Get secret from keyring given service and user name.
func Get(service, user string) (string, error) {
ch := make(chan struct {
val string
err error
}, 1)
go func() {
defer close(ch)
val, err := keyring.Get(service, user)
ch <- struct {
val string
err error
}{val, err}
}()
select {
case res := <-ch:
return res.val, res.err
case <-time.After(3 * time.Second):
return "", &TimeoutError{"timeout while trying to get secret from keyring"}
}
}
// Delete secret from keyring.
func Delete(service, user string) error {
ch := make(chan error, 1)
go func() {
defer close(ch)
ch <- keyring.Delete(service, user)
}()
select {
case err := <-ch:
return err
case <-time.After(3 * time.Second):
return &TimeoutError{"timeout while trying to delete secret from keyring"}
}
}

View file

@ -7,13 +7,14 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/surveyext"
)
//go:generate moq -rm -out prompter_mock.go . Prompter
type Prompter interface {
Select(string, string, []string) (int, error)
MultiSelect(string, string, []string) ([]string, error)
MultiSelect(string, []string, []string) ([]int, error)
Input(string, string) (string, error)
InputHostname() (string, error)
Password(string) (string, error)
@ -49,11 +50,24 @@ type surveyPrompter struct {
stderr io.Writer
}
// LatinMatchingFilter returns whether the value matches the input filter.
// The strings are compared normalized in case.
// The filter's diactritics are kept as-is, but the value's are normalized,
// so that a missing diactritic in the filter still returns a result.
func LatinMatchingFilter(filter, value string, index int) bool {
filter = strings.ToLower(filter)
value = strings.ToLower(value)
// include this option if it matches.
return strings.Contains(value, filter) || strings.Contains(text.RemoveDiacritics(value), filter)
}
func (p *surveyPrompter) Select(message, defaultValue string, options []string) (result int, err error) {
q := &survey.Select{
Message: message,
Options: options,
PageSize: 20,
Filter: LatinMatchingFilter,
}
if defaultValue != "" {
@ -72,15 +86,25 @@ func (p *surveyPrompter) Select(message, defaultValue string, options []string)
return
}
func (p *surveyPrompter) MultiSelect(message, defaultValue string, options []string) (result []string, err error) {
func (p *surveyPrompter) MultiSelect(message string, defaultValues, options []string) (result []int, err error) {
q := &survey.MultiSelect{
Message: message,
Options: options,
PageSize: 20,
Filter: LatinMatchingFilter,
}
if defaultValue != "" {
q.Default = defaultValue
if len(defaultValues) > 0 {
// TODO I don't actually know that this is needed, just being extra cautious
validatedDefault := []string{}
for _, x := range defaultValues {
for _, y := range options {
if x == y {
validatedDefault = append(validatedDefault, x)
}
}
}
q.Default = validatedDefault
}
err = p.ask(q, &result)

View file

@ -35,7 +35,7 @@ var _ Prompter = &PrompterMock{}
// MarkdownEditorFunc: func(s1 string, s2 string, b bool) (string, error) {
// panic("mock out the MarkdownEditor method")
// },
// MultiSelectFunc: func(s1 string, s2 string, strings []string) ([]string, error) {
// MultiSelectFunc: func(s string, strings1 []string, strings2 []string) ([]int, error) {
// panic("mock out the MultiSelect method")
// },
// PasswordFunc: func(s string) (string, error) {
@ -70,7 +70,7 @@ type PrompterMock struct {
MarkdownEditorFunc func(s1 string, s2 string, b bool) (string, error)
// MultiSelectFunc mocks the MultiSelect method.
MultiSelectFunc func(s1 string, s2 string, strings []string) ([]string, error)
MultiSelectFunc func(s string, strings1 []string, strings2 []string) ([]int, error)
// PasswordFunc mocks the Password method.
PasswordFunc func(s string) (string, error)
@ -116,12 +116,12 @@ type PrompterMock struct {
}
// MultiSelect holds details about calls to the MultiSelect method.
MultiSelect []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
// Strings is the strings argument value.
Strings []string
// S is the s argument value.
S string
// Strings1 is the strings1 argument value.
Strings1 []string
// Strings2 is the strings2 argument value.
Strings2 []string
}
// Password holds details about calls to the Password method.
Password []struct {
@ -348,23 +348,23 @@ func (mock *PrompterMock) MarkdownEditorCalls() []struct {
}
// MultiSelect calls MultiSelectFunc.
func (mock *PrompterMock) MultiSelect(s1 string, s2 string, strings []string) ([]string, error) {
func (mock *PrompterMock) MultiSelect(s string, strings1 []string, strings2 []string) ([]int, error) {
if mock.MultiSelectFunc == nil {
panic("PrompterMock.MultiSelectFunc: method is nil but Prompter.MultiSelect was just called")
}
callInfo := struct {
S1 string
S2 string
Strings []string
S string
Strings1 []string
Strings2 []string
}{
S1: s1,
S2: s2,
Strings: strings,
S: s,
Strings1: strings1,
Strings2: strings2,
}
mock.lockMultiSelect.Lock()
mock.calls.MultiSelect = append(mock.calls.MultiSelect, callInfo)
mock.lockMultiSelect.Unlock()
return mock.MultiSelectFunc(s1, s2, strings)
return mock.MultiSelectFunc(s, strings1, strings2)
}
// MultiSelectCalls gets all the calls that were made to MultiSelect.
@ -372,14 +372,14 @@ func (mock *PrompterMock) MultiSelect(s1 string, s2 string, strings []string) ([
//
// len(mockedPrompter.MultiSelectCalls())
func (mock *PrompterMock) MultiSelectCalls() []struct {
S1 string
S2 string
Strings []string
S string
Strings1 []string
Strings2 []string
} {
var calls []struct {
S1 string
S2 string
Strings []string
S string
Strings1 []string
Strings2 []string
}
mock.lockMultiSelect.RLock()
calls = mock.calls.MultiSelect

View file

@ -0,0 +1,78 @@
package prompter
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFilterDiacritics(t *testing.T) {
tests := []struct {
name string
filter string
value string
want bool
}{
{
name: "exact match no diacritics",
filter: "Mikelis",
value: "Mikelis",
want: true,
},
{
name: "exact match no diacritics",
filter: "Mikelis",
value: "Mikelis",
want: true,
},
{
name: "exact match diacritics",
filter: "Miķelis",
value: "Miķelis",
want: true,
},
{
name: "partial match diacritics",
filter: "Miķe",
value: "Miķelis",
want: true,
},
{
name: "exact match diacritics in value",
filter: "Mikelis",
value: "Miķelis",
want: true,
},
{
name: "partial match diacritics in filter",
filter: "Miķe",
value: "Miķelis",
want: true,
},
{
name: "no match when removing diacritics in filter",
filter: "Mielis",
value: "Mikelis",
want: false,
},
{
name: "no match when removing diacritics in value",
filter: "Mikelis",
value: "Mielis",
want: false,
},
{
name: "no match diacritics in filter",
filter: "Miķelis",
value: "Mikelis",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, LatinMatchingFilter(tt.filter, tt.value, 0), tt.want)
})
}
}

View file

@ -50,7 +50,7 @@ type ConfirmStub struct {
type MultiSelectStub struct {
Prompt string
ExpectedOpts []string
Fn func(string, string, []string) ([]string, error)
Fn func(string, []string, []string) ([]int, error)
}
type InputHostnameStub struct {
@ -84,8 +84,6 @@ type MockPrompter struct {
ConfirmDeletionStubs []ConfirmDeletionStub
}
// TODO thread safety
func NewMockPrompter(t *testing.T) *MockPrompter {
m := &MockPrompter{
t: t,
@ -120,17 +118,17 @@ func NewMockPrompter(t *testing.T) *MockPrompter {
return s.Fn(p, d, opts)
}
m.MultiSelectFunc = func(p, d string, opts []string) ([]string, error) {
m.MultiSelectFunc = func(p string, d, opts []string) ([]int, error) {
var s MultiSelectStub
if len(m.SelectStubs) > 0 {
if len(m.MultiSelectStubs) > 0 {
s = m.MultiSelectStubs[0]
m.SelectStubs = m.SelectStubs[1:len(m.SelectStubs)]
m.MultiSelectStubs = m.MultiSelectStubs[1:len(m.MultiSelectStubs)]
} else {
return []string{}, NoSuchPromptErr(p)
return []int{}, NoSuchPromptErr(p)
}
if s.Prompt != p {
return []string{}, NoSuchPromptErr(p)
return []int{}, NoSuchPromptErr(p)
}
AssertOptions(m.t, s.ExpectedOpts, opts)
@ -240,7 +238,7 @@ func (m *MockPrompter) RegisterSelect(prompt string, opts []string, stub func(_,
Fn: stub})
}
func (m *MockPrompter) RegisterMultiSelect(prompt string, opts []string, stub func(_, _ string, _ []string) ([]string, error)) {
func (m *MockPrompter) RegisterMultiSelect(prompt string, d, opts []string, stub func(_ string, _, _ []string) ([]int, error)) {
m.MultiSelectStubs = append(m.MultiSelectStubs, MultiSelectStub{
Prompt: prompt,
ExpectedOpts: opts,

View file

@ -6,7 +6,7 @@ import (
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/go-gh/pkg/tableprinter"
"github.com/cli/go-gh/v2/pkg/tableprinter"
)
type TablePrinter struct {

View file

@ -6,10 +6,14 @@ import (
"regexp"
"strings"
"time"
"unicode"
"github.com/cli/go-gh/pkg/text"
"github.com/cli/go-gh/v2/pkg/text"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"golang.org/x/text/runes"
"golang.org/x/text/transform"
"golang.org/x/text/unicode/norm"
)
var whitespaceRE = regexp.MustCompile(`\s+`)
@ -72,3 +76,19 @@ func DisplayURL(urlStr string) string {
}
return u.Hostname() + u.Path
}
// RemoveDiacritics returns the input value without "diacritics", or accent marks
func RemoveDiacritics(value string) string {
// Mn = "Mark, nonspacing" unicode character category
removeMnTransfomer := runes.Remove(runes.In(unicode.Mn))
// 1/ Decompose the text into characters and diacritical marks,
// 2/ Remove the diacriticals marks
// 3/ Recompose the text
t := transform.Chain(norm.NFD, removeMnTransfomer, norm.NFC)
normalized, _, err := transform.String(t, value)
if err != nil {
return value
}
return normalized
}

View file

@ -54,3 +54,57 @@ func TestFuzzyAgoAbbr(t *testing.T) {
assert.Equal(t, expected, fuzzy)
}
}
func TestRemoveDiacritics(t *testing.T) {
tests := [][]string{
// no diacritics
{"e", "e"},
{"و", "و"},
{"И", "И"},
{"ж", "ж"},
{"私", "私"},
{"万", "万"},
// diacritics test sets
{"à", "a"},
{"é", "e"},
{"è", "e"},
{"ô", "o"},
{"ᾳ", "α"},
{"εͅ", "ε"},
{"ῃ", "η"},
{"ιͅ", "ι"},
{"ؤ", "و"},
{"ā", "a"},
{"č", "c"},
{"ģ", "g"},
{"ķ", "k"},
{"ņ", "n"},
{"š", "s"},
{"ž", "z"},
{"ŵ", "w"},
{"ŷ", "y"},
{"ä", "a"},
{"ÿ", "y"},
{"á", "a"},
{"ẁ", "w"},
{"ỳ", "y"},
{"ō", "o"},
// full words
{"Miķelis", "Mikelis"},
{"François", "Francois"},
{"žluťoučký", "zlutoucky"},
{"învățătorița", "invatatorita"},
{"Kękę przy łóżku", "Keke przy łozku"},
}
for _, tt := range tests {
t.Run(RemoveDiacritics(tt[0]), func(t *testing.T) {
assert.Equal(t, tt[1], RemoveDiacritics(tt[0]))
})
}
}

View file

@ -42,7 +42,7 @@ func actionsExplainer(cs *iostreams.ColorScheme) string {
To see more help, run 'gh help run <subcommand>'
%s
gh workflow list: List all the workflow files in your repository
gh workflow list: List workflow files in your repository
gh workflow view: View details for a workflow file
gh workflow enable: Enable a workflow file
gh workflow disable: Disable a workflow file

View file

@ -3,6 +3,7 @@ package alias
import (
"github.com/MakeNowJust/heredoc"
deleteCmd "github.com/cli/cli/v2/pkg/cmd/alias/delete"
importCmd "github.com/cli/cli/v2/pkg/cmd/alias/imports"
listCmd "github.com/cli/cli/v2/pkg/cmd/alias/list"
setCmd "github.com/cli/cli/v2/pkg/cmd/alias/set"
"github.com/cli/cli/v2/pkg/cmdutil"
@ -23,6 +24,7 @@ func NewCmdAlias(f *cmdutil.Factory) *cobra.Command {
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(deleteCmd.NewCmdDelete(f, nil))
cmd.AddCommand(importCmd.NewCmdImport(f, nil))
cmd.AddCommand(listCmd.NewCmdList(f, nil))
cmd.AddCommand(setCmd.NewCmdSet(f, nil))

View file

@ -1,90 +0,0 @@
package expand
import (
"errors"
"fmt"
"os/exec"
"regexp"
"runtime"
"strings"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/findsh"
"github.com/google/shlex"
)
// ExpandAlias processes argv to see if it should be rewritten according to a user's aliases. The
// second return value indicates whether the alias should be executed in a new shell process instead
// of running gh itself.
func ExpandAlias(cfg config.Config, args []string, findShFunc func() (string, error)) (expanded []string, isShell bool, err error) {
if len(args) < 2 {
// the command is lacking a subcommand
return
}
expanded = args[1:]
aliases := cfg.Aliases()
expansion, getErr := aliases.Get(args[1])
if getErr != nil {
return
}
if strings.HasPrefix(expansion, "!") {
isShell = true
if findShFunc == nil {
findShFunc = findSh
}
shPath, shErr := findShFunc()
if shErr != nil {
err = shErr
return
}
expanded = []string{shPath, "-c", expansion[1:]}
if len(args[2:]) > 0 {
expanded = append(expanded, "--")
expanded = append(expanded, args[2:]...)
}
return
}
extraArgs := []string{}
for i, a := range args[2:] {
if !strings.Contains(expansion, "$") {
extraArgs = append(extraArgs, a)
} else {
expansion = strings.ReplaceAll(expansion, fmt.Sprintf("$%d", i+1), a)
}
}
lingeringRE := regexp.MustCompile(`\$\d`)
if lingeringRE.MatchString(expansion) {
err = fmt.Errorf("not enough arguments for alias: %s", expansion)
return
}
var newArgs []string
newArgs, err = shlex.Split(expansion)
if err != nil {
return
}
expanded = append(newArgs, extraArgs...)
return
}
func findSh() (string, error) {
shPath, err := findsh.Find()
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
if runtime.GOOS == "windows" {
return "", errors.New("unable to locate sh to execute the shell alias with. The sh.exe interpreter is typically distributed with Git for Windows.")
}
return "", errors.New("unable to locate sh to execute shell alias with")
}
return "", err
}
return shPath, nil
}

View file

@ -1,185 +0,0 @@
package expand
import (
"errors"
"reflect"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
)
func TestExpandAlias(t *testing.T) {
findShFunc := func() (string, error) {
return "/usr/bin/sh", nil
}
cfg := config.NewFromString(heredoc.Doc(`
aliases:
co: pr checkout
il: issue list --author="$1" --label="$2"
ia: issue list --author="$1" --assignee="$1"
`))
type args struct {
config config.Config
argv []string
}
tests := []struct {
name string
args args
wantExpanded []string
wantIsShell bool
wantErr error
}{
{
name: "no arguments",
args: args{
config: cfg,
argv: []string{},
},
wantExpanded: []string(nil),
wantIsShell: false,
wantErr: nil,
},
{
name: "too few arguments",
args: args{
config: cfg,
argv: []string{"gh"},
},
wantExpanded: []string(nil),
wantIsShell: false,
wantErr: nil,
},
{
name: "no expansion",
args: args{
config: cfg,
argv: []string{"gh", "pr", "status"},
},
wantExpanded: []string{"pr", "status"},
wantIsShell: false,
wantErr: nil,
},
{
name: "simple expansion",
args: args{
config: cfg,
argv: []string{"gh", "co"},
},
wantExpanded: []string{"pr", "checkout"},
wantIsShell: false,
wantErr: nil,
},
{
name: "adding arguments after expansion",
args: args{
config: cfg,
argv: []string{"gh", "co", "123"},
},
wantExpanded: []string{"pr", "checkout", "123"},
wantIsShell: false,
wantErr: nil,
},
{
name: "not enough arguments for expansion",
args: args{
config: cfg,
argv: []string{"gh", "il"},
},
wantExpanded: []string{},
wantIsShell: false,
wantErr: errors.New(`not enough arguments for alias: issue list --author="$1" --label="$2"`),
},
{
name: "not enough arguments for expansion 2",
args: args{
config: cfg,
argv: []string{"gh", "il", "vilmibm"},
},
wantExpanded: []string{},
wantIsShell: false,
wantErr: errors.New(`not enough arguments for alias: issue list --author="vilmibm" --label="$2"`),
},
{
name: "satisfy expansion arguments",
args: args{
config: cfg,
argv: []string{"gh", "il", "vilmibm", "help wanted"},
},
wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=help wanted"},
wantIsShell: false,
wantErr: nil,
},
{
name: "mixed positional and non-positional arguments",
args: args{
config: cfg,
argv: []string{"gh", "il", "vilmibm", "epic", "-R", "monalisa/testing"},
},
wantExpanded: []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "monalisa/testing"},
wantIsShell: false,
wantErr: nil,
},
{
name: "dollar in expansion",
args: args{
config: cfg,
argv: []string{"gh", "ia", "$coolmoney$"},
},
wantExpanded: []string{"issue", "list", "--author=$coolmoney$", "--assignee=$coolmoney$"},
wantIsShell: false,
wantErr: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotExpanded, gotIsShell, err := ExpandAlias(tt.args.config, tt.args.argv, findShFunc)
if tt.wantErr != nil {
if err == nil {
t.Fatal("expected error")
}
if tt.wantErr.Error() != err.Error() {
t.Fatalf("expected error %q, got %q", tt.wantErr, err)
}
return
}
if err != nil {
t.Fatalf("got error: %v", err)
}
if !reflect.DeepEqual(gotExpanded, tt.wantExpanded) {
t.Errorf("ExpandAlias() gotExpanded = %v, want %v", gotExpanded, tt.wantExpanded)
}
if gotIsShell != tt.wantIsShell {
t.Errorf("ExpandAlias() gotIsShell = %v, want %v", gotIsShell, tt.wantIsShell)
}
})
}
}
// cfg := `---
// aliases:
// co: pr checkout
// il: issue list --author="$1" --label="$2"
// ia: issue list --author="$1" --assignee="$1"
// `
// initBlankContext(cfg, "OWNER/REPO", "trunk")
// for _, c := range []struct {
// Args string
// ExpectedArgs []string
// Err string
// }{
// {"gh co", []string{"pr", "checkout"}, ""},
// {"gh il", nil, `not enough arguments for alias: issue list --author="$1" --label="$2"`},
// {"gh il vilmibm", nil, `not enough arguments for alias: issue list --author="vilmibm" --label="$2"`},
// {"gh co 123", []string{"pr", "checkout", "123"}, ""},
// {"gh il vilmibm epic", []string{"issue", "list", `--author=vilmibm`, `--label=epic`}, ""},
// {"gh ia vilmibm", []string{"issue", "list", `--author=vilmibm`, `--assignee=vilmibm`}, ""},
// {"gh ia $coolmoney$", []string{"issue", "list", `--author=$coolmoney$`, `--assignee=$coolmoney$`}, ""},
// {"gh pr status", []string{"pr", "status"}, ""},
// {"gh il vilmibm epic -R vilmibm/testing", []string{"issue", "list", "--author=vilmibm", "--label=epic", "-R", "vilmibm/testing"}, ""},
// {"gh dne", []string{"dne"}, ""},
// {"gh", []string{}, ""},
// {"", []string{}, ""},
// } {

View file

@ -0,0 +1,201 @@
package imports
import (
"fmt"
"sort"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
type ImportOptions struct {
Config func() (config.Config, error)
IO *iostreams.IOStreams
Filename string
OverwriteExisting bool
validAliasName func(string) bool
validAliasExpansion func(string) bool
}
func NewCmdImport(f *cmdutil.Factory, runF func(*ImportOptions) error) *cobra.Command {
opts := &ImportOptions{
IO: f.IOStreams,
Config: f.Config,
}
cmd := &cobra.Command{
Use: "import [<filename> | -]",
Short: "Import aliases from a YAML file",
Long: heredoc.Doc(`
Import aliases from the contents of a YAML file.
Aliases should be defined as a map in YAML, where the keys represent aliases and
the values represent the corresponding expansions. An example file should look like
the following:
bugs: issue list --label=bug
igrep: '!gh issue list --label="$1" | grep "$2"'
features: |-
issue list
--label=enhancement
Use "-" to read aliases (in YAML format) from standard input.
The output from the gh command "alias list" can be used to produce a YAML file
containing your aliases, which you can use to import them from one machine to
another. Run "gh help alias list" to learn more.
`),
Example: heredoc.Doc(`
# Import aliases from a file
$ gh alias import aliases.yml
# Import aliases from standard input
$ gh alias import -
`),
Args: func(cmd *cobra.Command, args []string) error {
if len(args) > 1 {
return cmdutil.FlagErrorf("too many arguments")
}
if len(args) == 0 && opts.IO.IsStdinTTY() {
return cmdutil.FlagErrorf("no filename passed and nothing on STDIN")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
opts.Filename = "-"
if len(args) > 0 {
opts.Filename = args[0]
}
opts.validAliasName = shared.ValidAliasNameFunc(cmd)
opts.validAliasExpansion = shared.ValidAliasExpansionFunc(cmd)
if runF != nil {
return runF(opts)
}
return importRun(opts)
},
}
cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing aliases of the same name")
return cmd
}
func importRun(opts *ImportOptions) error {
cs := opts.IO.ColorScheme()
cfg, err := opts.Config()
if err != nil {
return err
}
aliasCfg := cfg.Aliases()
b, err := cmdutil.ReadFile(opts.Filename, opts.IO.In)
if err != nil {
return err
}
aliasMap := map[string]string{}
if err = yaml.Unmarshal(b, &aliasMap); err != nil {
return err
}
isTerminal := opts.IO.IsStdoutTTY()
if isTerminal {
if opts.Filename == "-" {
fmt.Fprintf(opts.IO.ErrOut, "- Importing aliases from standard input\n")
} else {
fmt.Fprintf(opts.IO.ErrOut, "- Importing aliases from file %q\n", opts.Filename)
}
}
var msg strings.Builder
for _, alias := range getSortedKeys(aliasMap) {
var existingAlias bool
if _, err := aliasCfg.Get(alias); err == nil {
existingAlias = true
}
if !opts.validAliasName(alias) {
if !existingAlias {
msg.WriteString(
fmt.Sprintf("%s Could not import alias %s: already a gh command or extension\n",
cs.FailureIcon(),
cs.Bold(alias),
),
)
continue
}
if existingAlias && !opts.OverwriteExisting {
msg.WriteString(
fmt.Sprintf("%s Could not import alias %s: name already taken\n",
cs.FailureIcon(),
cs.Bold(alias),
),
)
continue
}
}
expansion := aliasMap[alias]
if !opts.validAliasExpansion(expansion) {
msg.WriteString(
fmt.Sprintf("%s Could not import alias %s: expansion does not correspond to a gh command, extension, or alias\n",
cs.FailureIcon(),
cs.Bold(alias),
),
)
continue
}
aliasCfg.Add(alias, expansion)
if existingAlias && opts.OverwriteExisting {
msg.WriteString(
fmt.Sprintf("%s Changed alias %s\n",
cs.WarningIcon(),
cs.Bold(alias),
),
)
} else {
msg.WriteString(
fmt.Sprintf("%s Added alias %s\n",
cs.SuccessIcon(),
cs.Bold(alias),
),
)
}
}
if err := cfg.Write(); err != nil {
return err
}
if isTerminal {
fmt.Fprintln(opts.IO.ErrOut, msg.String())
}
return nil
}
func getSortedKeys(m map[string]string) []string {
keys := []string{}
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}

View file

@ -0,0 +1,349 @@
package imports
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCmdImport(t *testing.T) {
tests := []struct {
name string
cli string
tty bool
wants ImportOptions
wantsError string
}{
{
name: "no filename and stdin tty",
cli: "",
tty: true,
wants: ImportOptions{
Filename: "",
OverwriteExisting: false,
},
wantsError: "no filename passed and nothing on STDIN",
},
{
name: "no filename and stdin is not tty",
cli: "",
tty: false,
wants: ImportOptions{
Filename: "-",
OverwriteExisting: false,
},
},
{
name: "stdin arg",
cli: "-",
wants: ImportOptions{
Filename: "-",
OverwriteExisting: false,
},
},
{
name: "multiple filenames",
cli: "aliases1 aliases2",
wants: ImportOptions{
Filename: "aliases1 aliases2",
OverwriteExisting: false,
},
wantsError: "too many arguments",
},
{
name: "clobber flag",
cli: "aliases --clobber",
wants: ImportOptions{
Filename: "aliases",
OverwriteExisting: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
ios.SetStdinTTY(tt.tty)
f := &cmdutil.Factory{IOStreams: ios}
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
var gotOpts *ImportOptions
cmd := NewCmdImport(f, func(opts *ImportOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
_, err = cmd.ExecuteC()
if tt.wantsError != "" {
assert.EqualError(t, err, tt.wantsError)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.Filename, gotOpts.Filename)
assert.Equal(t, tt.wants.OverwriteExisting, gotOpts.OverwriteExisting)
})
}
}
func TestImportRun(t *testing.T) {
tmpFile := filepath.Join(t.TempDir(), "aliases.yml")
importFileMsg := fmt.Sprintf(`- Importing aliases from file %q`, tmpFile)
importStdinMsg := "- Importing aliases from standard input"
tests := []struct {
name string
opts *ImportOptions
stdin string
fileContents string
initConfig string
aliasCommands []*cobra.Command
wantConfig string
wantStderr string
}{
{
name: "with no existing aliases",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
co: pr checkout
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
wantConfig: heredoc.Doc(`
aliases:
co: pr checkout
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
wantStderr: importFileMsg + "\n✓ Added alias co\n✓ Added alias igrep\n\n",
},
{
name: "with existing aliases",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
users: |-
api graphql -F name="$1" -f query='
query ($name: String!) {
user(login: $name) {
name
}
}'
co: pr checkout
`),
initConfig: heredoc.Doc(`
aliases:
igrep: '!gh issue list --label="$1" | grep "$2"'
editor: vim
`),
aliasCommands: []*cobra.Command{
{Use: "igrep"},
},
wantConfig: heredoc.Doc(`
aliases:
igrep: '!gh issue list --label="$1" | grep "$2"'
co: pr checkout
users: |-
api graphql -F name="$1" -f query='
query ($name: String!) {
user(login: $name) {
name
}
}'
editor: vim
`),
wantStderr: importFileMsg + "\n✓ Added alias co\n✓ Added alias users\n\n",
},
{
name: "from stdin",
opts: &ImportOptions{
Filename: "-",
OverwriteExisting: false,
},
stdin: heredoc.Doc(`
co: pr checkout
features: |-
issue list
--label=enhancement
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
wantConfig: heredoc.Doc(`
aliases:
co: pr checkout
features: |-
issue list
--label=enhancement
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
wantStderr: importStdinMsg + "\n✓ Added alias co\n✓ Added alias features\n✓ Added alias igrep\n\n",
},
{
name: "already taken aliases",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
co: pr checkout -R cool/repo
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
initConfig: heredoc.Doc(`
aliases:
co: pr checkout
editor: vim
`),
aliasCommands: []*cobra.Command{
{Use: "co"},
},
wantConfig: heredoc.Doc(`
aliases:
co: pr checkout
igrep: '!gh issue list --label="$1" | grep "$2"'
editor: vim
`),
wantStderr: importFileMsg + "\nX Could not import alias co: name already taken\n✓ Added alias igrep\n\n",
},
{
name: "override aliases",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: true,
},
fileContents: heredoc.Doc(`
co: pr checkout -R cool/repo
igrep: '!gh issue list --label="$1" | grep "$2"'
`),
initConfig: heredoc.Doc(`
aliases:
co: pr checkout
editor: vim
`),
aliasCommands: []*cobra.Command{
{Use: "co"},
},
wantConfig: heredoc.Doc(`
aliases:
co: pr checkout -R cool/repo
igrep: '!gh issue list --label="$1" | grep "$2"'
editor: vim
`),
wantStderr: importFileMsg + "\n! Changed alias co\n✓ Added alias igrep\n\n",
},
{
name: "alias is a gh command",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
pr: pr checkout
issue: issue list
api: api graphql
`),
wantStderr: strings.Join(
[]string{
importFileMsg,
"X Could not import alias api: already a gh command or extension",
"X Could not import alias issue: already a gh command or extension",
"X Could not import alias pr: already a gh command or extension\n\n",
},
"\n",
),
},
{
name: "invalid expansion",
opts: &ImportOptions{
Filename: tmpFile,
OverwriteExisting: false,
},
fileContents: heredoc.Doc(`
alias1:
alias2: ps checkout
`),
wantStderr: strings.Join(
[]string{
importFileMsg,
"X Could not import alias alias1: expansion does not correspond to a gh command, extension, or alias",
"X Could not import alias alias2: expansion does not correspond to a gh command, extension, or alias\n\n",
},
"\n",
),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.fileContents != "" {
err := os.WriteFile(tmpFile, []byte(tt.fileContents), 0600)
require.NoError(t, err)
}
ios, stdin, _, stderr := iostreams.Test()
ios.SetStdinTTY(true)
ios.SetStdoutTTY(true)
ios.SetStderrTTY(true)
tt.opts.IO = ios
readConfigs := config.StubWriteConfig(t)
cfg := config.NewFromString(tt.initConfig)
tt.opts.Config = func() (config.Config, error) {
return cfg, nil
}
// Create fake command structure for testing.
rootCmd := &cobra.Command{}
prCmd := &cobra.Command{Use: "pr"}
prCmd.AddCommand(&cobra.Command{Use: "checkout"})
prCmd.AddCommand(&cobra.Command{Use: "status"})
rootCmd.AddCommand(prCmd)
issueCmd := &cobra.Command{Use: "issue"}
issueCmd.AddCommand(&cobra.Command{Use: "list"})
rootCmd.AddCommand(issueCmd)
apiCmd := &cobra.Command{Use: "api"}
apiCmd.AddCommand(&cobra.Command{Use: "graphql"})
rootCmd.AddCommand(apiCmd)
for _, cmd := range tt.aliasCommands {
rootCmd.AddCommand(cmd)
}
tt.opts.validAliasName = shared.ValidAliasNameFunc(rootCmd)
tt.opts.validAliasExpansion = shared.ValidAliasExpansionFunc(rootCmd)
if tt.stdin != "" {
stdin.WriteString(tt.stdin)
}
err := importRun(tt.opts)
require.NoError(t, err)
configOut := bytes.Buffer{}
readConfigs(&configOut, io.Discard)
assert.Equal(t, tt.wantStderr, stderr.String())
assert.Equal(t, tt.wantConfig, configOut.String())
})
}
}

View file

@ -7,9 +7,9 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/spf13/cobra"
)
@ -21,7 +21,8 @@ type SetOptions struct {
Expansion string
IsShell bool
validCommand func(string) bool
validAliasName func(string) bool
validAliasExpansion func(string) bool
}
func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command {
@ -59,6 +60,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
$ gh alias set homework 'issue list --assignee @me'
$ gh homework
$ gh alias set 'issue mine' 'issue list --mention @me'
$ gh issue mine
$ gh alias set epicsBy 'issue list --author="$1" --label="epic"'
$ gh epicsBy vilmibm #=> gh issue list --author="vilmibm" --label="epic"
@ -70,29 +74,13 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
opts.Name = args[0]
opts.Expansion = args[1]
opts.validCommand = func(args string) bool {
split, err := shlex.Split(args)
if err != nil {
return false
}
rootCmd := cmd.Root()
cmd, _, err := rootCmd.Traverse(split)
if err == nil && cmd != rootCmd {
return true
}
for _, ext := range f.ExtensionManager.List() {
if ext.Name() == split[0] {
return true
}
}
return false
}
opts.validAliasName = shared.ValidAliasNameFunc(cmd)
opts.validAliasExpansion = shared.ValidAliasExpansionFunc(cmd)
if runF != nil {
return runF(opts)
}
return setRun(opts)
},
}
@ -121,18 +109,16 @@ func setRun(opts *SetOptions) error {
fmt.Fprintf(opts.IO.ErrOut, "- Adding alias for %s: %s\n", cs.Bold(opts.Name), cs.Bold(expansion))
}
isShell := opts.IsShell
if isShell && !strings.HasPrefix(expansion, "!") {
if opts.IsShell && !strings.HasPrefix(expansion, "!") {
expansion = "!" + expansion
}
isShell = strings.HasPrefix(expansion, "!")
if opts.validCommand(opts.Name) {
return fmt.Errorf("could not create alias: %q is already a gh command", opts.Name)
if !opts.validAliasName(opts.Name) {
return fmt.Errorf("could not create alias: %q is already a gh command, extension, or alias", opts.Name)
}
if !isShell && !opts.validCommand(expansion) {
return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion)
if !opts.validAliasExpansion(expansion) {
return fmt.Errorf("could not create alias: %s does not correspond to a gh command, extension, or alias", expansion)
}
successMsg := fmt.Sprintf("%s Added alias.", cs.SuccessIcon())

View file

@ -38,7 +38,7 @@ func runCommand(cfg config.Config, isTTY bool, cli string, in string) (*test.Cmd
cmd := NewCmdSet(factory, nil)
// fake command nesting structure needed for validCommand
// Create fake command structure for testing.
rootCmd := &cobra.Command{}
rootCmd.AddCommand(cmd)
prCmd := &cobra.Command{Use: "pr"}
@ -73,7 +73,7 @@ func TestAliasSet_gh_command(t *testing.T) {
cfg := config.NewFromString(``)
_, err := runCommand(cfg, true, "pr 'pr status'", "")
assert.EqualError(t, err, `could not create alias: "pr" is already a gh command`)
assert.EqualError(t, err, `could not create alias: "pr" is already a gh command, extension, or alias`)
}
func TestAliasSet_empty_aliases(t *testing.T) {
@ -231,7 +231,7 @@ func TestAliasSet_invalid_command(t *testing.T) {
cfg := config.NewFromString(``)
_, err := runCommand(cfg, true, "co 'pe checkout'", "")
assert.EqualError(t, err, "could not create alias: pe checkout does not correspond to a gh command")
assert.EqualError(t, err, "could not create alias: pe checkout does not correspond to a gh command, extension, or alias")
}
func TestShellAlias_flag(t *testing.T) {

View file

@ -0,0 +1,51 @@
package shared
import (
"strings"
"github.com/google/shlex"
"github.com/spf13/cobra"
)
// ValidAliasNameFunc returns a function that will check if the given string
// is a valid alias name. A name is valid if:
// - it does not shadow an existing command,
// - it is not nested under a command that is runnable,
// - it is not nested under a command that does not exist.
func ValidAliasNameFunc(cmd *cobra.Command) func(string) bool {
return func(args string) bool {
split, err := shlex.Split(args)
if err != nil || len(split) == 0 {
return false
}
rootCmd := cmd.Root()
foundCmd, foundArgs, _ := rootCmd.Find(split)
if foundCmd != nil && !foundCmd.Runnable() && len(foundArgs) == 1 {
return true
}
return false
}
}
// ValidAliasExpansionFunc returns a function that will check if the given string
// is a valid alias expansion. An expansion is valid if:
// - it is a shell expansion,
// - it is a non-shell expansion that corresponds to an existing command, extension, or alias.
func ValidAliasExpansionFunc(cmd *cobra.Command) func(string) bool {
return func(expansion string) bool {
if strings.HasPrefix(expansion, "!") {
return true
}
split, err := shlex.Split(expansion)
if err != nil || len(split) == 0 {
return false
}
rootCmd := cmd.Root()
cmd, _, _ = rootCmd.Find(split)
return cmd != rootCmd
}
}

View file

@ -0,0 +1,54 @@
package shared
import (
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)
func TestValidAliasNameFunc(t *testing.T) {
// Create fake command structure for testing.
issueCmd := &cobra.Command{Use: "issue"}
prCmd := &cobra.Command{Use: "pr"}
prCmd.AddCommand(&cobra.Command{Use: "checkout"})
cmd := &cobra.Command{}
cmd.AddCommand(prCmd)
cmd.AddCommand(issueCmd)
f := ValidAliasNameFunc(cmd)
assert.False(t, f("pr"))
assert.False(t, f("pr checkout"))
assert.False(t, f("issue"))
assert.False(t, f("repo list"))
assert.True(t, f("ps"))
assert.True(t, f("checkout"))
assert.True(t, f("issue erase"))
assert.True(t, f("pr erase"))
assert.True(t, f("pr checkout branch"))
}
func TestValidAliasExpansionFunc(t *testing.T) {
// Create fake command structure for testing.
issueCmd := &cobra.Command{Use: "issue"}
prCmd := &cobra.Command{Use: "pr"}
prCmd.AddCommand(&cobra.Command{Use: "checkout"})
cmd := &cobra.Command{}
cmd.AddCommand(prCmd)
cmd.AddCommand(issueCmd)
f := ValidAliasExpansionFunc(cmd)
assert.False(t, f("ps"))
assert.False(t, f("checkout"))
assert.False(t, f("repo list"))
assert.True(t, f("!git branch --show-current"))
assert.True(t, f("pr"))
assert.True(t, f("pr checkout"))
assert.True(t, f("issue"))
}

View file

@ -3,6 +3,7 @@ package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@ -23,8 +24,8 @@ import (
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/jsoncolor"
"github.com/cli/go-gh/pkg/jq"
"github.com/cli/go-gh/pkg/template"
"github.com/cli/go-gh/v2/pkg/jq"
"github.com/cli/go-gh/v2/pkg/template"
"github.com/spf13/cobra"
)
@ -321,6 +322,7 @@ func apiRun(opts *ApiOptions) error {
return err
}
isFirstPage := true
hasNextPage := true
for hasNextPage {
resp, err := httpRequest(httpClient, host, method, requestPath, requestBody, requestHeaders)
@ -328,10 +330,16 @@ func apiRun(opts *ApiOptions) error {
return err
}
endCursor, err := processResponse(resp, opts, bodyWriter, headersWriter, &tmpl)
if !isGraphQL {
requestPath, hasNextPage = findNextPage(resp)
requestBody = nil // prevent repeating GET parameters
}
endCursor, err := processResponse(resp, opts, bodyWriter, headersWriter, tmpl, isFirstPage, !hasNextPage)
if err != nil {
return err
}
isFirstPage = false
if !opts.Paginate {
break
@ -342,9 +350,6 @@ func apiRun(opts *ApiOptions) error {
if hasNextPage {
params["endCursor"] = endCursor
}
} else {
requestPath, hasNextPage = findNextPage(resp)
requestBody = nil // prevent repeating GET parameters
}
if hasNextPage && opts.ShowResponseHeaders {
@ -355,7 +360,7 @@ func apiRun(opts *ApiOptions) error {
return tmpl.Flush()
}
func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersWriter io.Writer, template *template.Template) (endCursor string, err error) {
func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersWriter io.Writer, template *template.Template, isFirstPage, isLastPage bool) (endCursor string, err error) {
if opts.ShowResponseHeaders {
fmt.Fprintln(headersWriter, resp.Proto, resp.Status)
printHeaders(headersWriter, resp.Header, opts.IO.ColorEnabled())
@ -387,7 +392,11 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW
if opts.FilterOutput != "" && serverError == "" {
// TODO: reuse parsed query across pagination invocations
err = jq.Evaluate(responseBody, bodyWriter, opts.FilterOutput)
indent := ""
if opts.IO.IsStdoutTTY() {
indent = " "
}
err = jq.EvaluateFormatted(responseBody, bodyWriter, opts.FilterOutput, indent, opts.IO.ColorEnabled())
if err != nil {
return
}
@ -399,6 +408,13 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW
} else if isJSON && opts.IO.ColorEnabled() {
err = jsoncolor.Write(bodyWriter, responseBody, " ")
} else {
if isJSON && opts.Paginate && !isGraphQLPaginate && !opts.ShowResponseHeaders {
responseBody = &paginatedArrayReader{
Reader: responseBody,
isFirstPage: isFirstPage,
isLastPage: isLastPage,
}
}
_, err = io.Copy(bodyWriter, responseBody)
}
if err != nil {
@ -454,6 +470,11 @@ func fillPlaceholders(value string, opts *ApiOptions) (string, error) {
err = e
}
case "branch":
if os.Getenv("GH_REPO") != "" {
err = errors.New("unable to determine an appropriate value for the 'branch' placeholder")
return m
}
if branch, e := opts.Branch(); e == nil {
return branch
} else {

View file

@ -19,7 +19,7 @@ import (
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/go-gh/pkg/template"
"github.com/cli/go-gh/v2/pkg/template"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -378,6 +378,7 @@ func Test_apiRun(t *testing.T) {
err error
stdout string
stderr string
isatty bool
}{
{
name: "success",
@ -388,6 +389,7 @@ func Test_apiRun(t *testing.T) {
err: nil,
stdout: `bam!`,
stderr: ``,
isatty: false,
},
{
name: "show response headers",
@ -404,6 +406,7 @@ func Test_apiRun(t *testing.T) {
err: nil,
stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\nbody",
stderr: ``,
isatty: false,
},
{
name: "success 204",
@ -414,6 +417,7 @@ func Test_apiRun(t *testing.T) {
err: nil,
stdout: ``,
stderr: ``,
isatty: false,
},
{
name: "REST error",
@ -425,6 +429,7 @@ func Test_apiRun(t *testing.T) {
err: cmdutil.SilentError,
stdout: `{"message": "THIS IS FINE"}`,
stderr: "gh: THIS IS FINE (HTTP 400)\n",
isatty: false,
},
{
name: "REST string errors",
@ -436,6 +441,7 @@ func Test_apiRun(t *testing.T) {
err: cmdutil.SilentError,
stdout: `{"errors": ["ALSO", "FINE"]}`,
stderr: "gh: ALSO\nFINE\n",
isatty: false,
},
{
name: "GraphQL error",
@ -450,6 +456,7 @@ func Test_apiRun(t *testing.T) {
err: cmdutil.SilentError,
stdout: `{"errors": [{"message":"AGAIN"}, {"message":"FINE"}]}`,
stderr: "gh: AGAIN\nFINE\n",
isatty: false,
},
{
name: "failure",
@ -460,6 +467,7 @@ func Test_apiRun(t *testing.T) {
err: cmdutil.SilentError,
stdout: `gateway timeout`,
stderr: "gh: HTTP 502\n",
isatty: false,
},
{
name: "silent",
@ -473,6 +481,7 @@ func Test_apiRun(t *testing.T) {
err: nil,
stdout: ``,
stderr: ``,
isatty: false,
},
{
name: "show response headers even when silent",
@ -490,6 +499,7 @@ func Test_apiRun(t *testing.T) {
err: nil,
stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\n",
stderr: ``,
isatty: false,
},
{
name: "output template",
@ -504,6 +514,7 @@ func Test_apiRun(t *testing.T) {
err: nil,
stdout: "not a cat",
stderr: ``,
isatty: false,
},
{
name: "output template when REST error",
@ -518,6 +529,7 @@ func Test_apiRun(t *testing.T) {
err: cmdutil.SilentError,
stdout: `{"message": "THIS IS FINE"}`,
stderr: "gh: THIS IS FINE (HTTP 400)\n",
isatty: false,
},
{
name: "jq filter",
@ -532,6 +544,7 @@ func Test_apiRun(t *testing.T) {
err: nil,
stdout: "Mona\nHubot\n",
stderr: ``,
isatty: false,
},
{
name: "jq filter when REST error",
@ -546,12 +559,29 @@ func Test_apiRun(t *testing.T) {
err: cmdutil.SilentError,
stdout: `{"message": "THIS IS FINE"}`,
stderr: "gh: THIS IS FINE (HTTP 400)\n",
isatty: false,
},
{
name: "jq filter outputting JSON to a TTY",
options: ApiOptions{
FilterOutput: `.`,
},
httpResponse: &http.Response{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`[{"name":"Mona"},{"name":"Hubot"}]`)),
Header: http.Header{"Content-Type": []string{"application/json"}},
},
err: nil,
stdout: "[\n {\n \"name\": \"Mona\"\n },\n {\n \"name\": \"Hubot\"\n }\n]\n",
stderr: ``,
isatty: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(tt.isatty)
tt.options.IO = ios
tt.options.Config = func() (config.Config, error) { return config.NewBlankConfig(), nil }
@ -638,6 +668,78 @@ func Test_apiRun_paginationREST(t *testing.T) {
assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=3", responses[2].Request.URL.String())
}
func Test_apiRun_arrayPaginationREST(t *testing.T) {
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(false)
requestCount := 0
responses := []*http.Response{
{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`[{"item":1},{"item":2}]`)),
Header: http.Header{
"Content-Type": []string{"application/json"},
"Link": []string{`<https://api.github.com/repositories/1227/issues?page=2>; rel="next", <https://api.github.com/repositories/1227/issues?page=4>; rel="last"`},
},
},
{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`[{"item":3},{"item":4}]`)),
Header: http.Header{
"Content-Type": []string{"application/json"},
"Link": []string{`<https://api.github.com/repositories/1227/issues?page=3>; rel="next", <https://api.github.com/repositories/1227/issues?page=4>; rel="last"`},
},
},
{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`[{"item":5}]`)),
Header: http.Header{
"Content-Type": []string{"application/json"},
"Link": []string{`<https://api.github.com/repositories/1227/issues?page=4>; rel="next", <https://api.github.com/repositories/1227/issues?page=4>; rel="last"`},
},
},
{
StatusCode: 200,
Body: io.NopCloser(bytes.NewBufferString(`[]`)),
Header: http.Header{
"Content-Type": []string{"application/json"},
},
},
}
options := ApiOptions{
IO: ios,
HttpClient: func() (*http.Client, error) {
var tr roundTripper = func(req *http.Request) (*http.Response, error) {
resp := responses[requestCount]
resp.Request = req
requestCount++
return resp, nil
}
return &http.Client{Transport: tr}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
RequestMethod: "GET",
RequestMethodPassed: true,
RequestPath: "issues",
Paginate: true,
RawFields: []string{"per_page=50", "page=1"},
}
err := apiRun(&options)
assert.NoError(t, err)
assert.Equal(t, `[{"item":1},{"item":2},{"item":3},{"item":4},{"item":5} ]`, stdout.String(), "stdout")
assert.Equal(t, "", stderr.String(), "stderr")
assert.Equal(t, "https://api.github.com/issues?page=1&per_page=50", responses[0].Request.URL.String())
assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=2", responses[1].Request.URL.String())
assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=3", responses[2].Request.URL.String())
}
func Test_apiRun_paginationGraphQL(t *testing.T) {
ios, _, stdout, stderr := iostreams.Test()
@ -994,10 +1096,11 @@ func Test_fillPlaceholders(t *testing.T) {
opts *ApiOptions
}
tests := []struct {
name string
args args
want string
wantErr bool
name string
args args
repoOverride bool
want string
wantErr bool
}{
{
name: "no changes",
@ -1134,9 +1237,26 @@ func Test_fillPlaceholders(t *testing.T) {
want: "{}{ownership}/{repository}",
wantErr: false,
},
{
name: "branch can't be filled when GH_REPO is set",
repoOverride: true,
args: args{
value: "repos/:owner/:repo/branches/:branch",
opts: &ApiOptions{
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("hubot", "robot-uprising"), nil
},
},
},
want: "repos/hubot/robot-uprising/branches/:branch",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.repoOverride {
t.Setenv("GH_REPO", "hubot/robot-uprising")
}
got, err := fillPlaceholders(tt.args.value, tt.args.opts)
if (err != nil) != tt.wantErr {
t.Errorf("fillPlaceholders() error = %v, wantErr %v", err, tt.wantErr)
@ -1206,7 +1326,7 @@ func Test_processResponse_template(t *testing.T) {
tmpl := template.New(ios.Out, ios.TerminalWidth(), ios.ColorEnabled())
err := tmpl.Parse(opts.Template)
require.NoError(t, err)
_, err = processResponse(&resp, &opts, ios.Out, io.Discard, &tmpl)
_, err = processResponse(&resp, &opts, ios.Out, io.Discard, tmpl, true, true)
require.NoError(t, err)
err = tmpl.Flush()
require.NoError(t, err)

View file

@ -50,7 +50,7 @@ func httpRequest(client *http.Client, hostname string, method string, p string,
return nil, fmt.Errorf("unrecognized parameters type: %v", params)
}
req, err := http.NewRequest(method, requestURL, body)
req, err := http.NewRequest(strings.ToUpper(method), requestURL, body)
if err != nil {
return nil, err
}
@ -103,21 +103,8 @@ func addQuery(path string, params map[string]interface{}) string {
}
query := url.Values{}
for key, value := range params {
switch v := value.(type) {
case string:
query.Add(key, v)
case []byte:
query.Add(key, string(v))
case nil:
query.Add(key, "")
case int:
query.Add(key, fmt.Sprintf("%d", v))
case bool:
query.Add(key, fmt.Sprintf("%v", v))
default:
panic(fmt.Sprintf("unknown type %v", v))
}
if err := addQueryParam(query, "", params); err != nil {
panic(err)
}
sep := "?"
@ -126,3 +113,34 @@ func addQuery(path string, params map[string]interface{}) string {
}
return path + sep + query.Encode()
}
func addQueryParam(query url.Values, key string, value interface{}) error {
switch v := value.(type) {
case string:
query.Add(key, v)
case []byte:
query.Add(key, string(v))
case nil:
query.Add(key, "")
case int:
query.Add(key, fmt.Sprintf("%d", v))
case bool:
query.Add(key, fmt.Sprintf("%v", v))
case map[string]interface{}:
for subkey, value := range v {
// support for nested subkeys can be added here if that is ever necessary
if err := addQueryParam(query, subkey, value); err != nil {
return err
}
}
case []interface{}:
for _, entry := range v {
if err := addQueryParam(query, key+"[]", entry); err != nil {
return err
}
}
default:
return fmt.Errorf("unknown type %v", v)
}
return nil
}

View file

@ -129,6 +129,24 @@ func Test_httpRequest(t *testing.T) {
headers: "",
},
},
{
name: "lowercase HTTP method",
args: args{
client: &httpClient,
host: "github.com",
method: "get",
p: "repos/octocat/spoon-knife",
params: nil,
headers: []string{},
},
wantErr: false,
want: expects{
method: "GET",
u: "https://api.github.com/repos/octocat/spoon-knife",
body: "",
headers: "",
},
},
{
name: "GET with leading slash",
args: args{
@ -322,6 +340,14 @@ func Test_addQuery(t *testing.T) {
},
want: "?a=hello",
},
{
name: "array",
args: args{
path: "",
params: map[string]interface{}{"a": []interface{}{"hello", "world"}},
},
want: "?a%5B%5D=hello&a%5B%5D=world",
},
{
name: "append",
args: args{

View file

@ -106,3 +106,43 @@ func addPerPage(p string, perPage int, params map[string]interface{}) string {
return fmt.Sprintf("%s%sper_page=%d", p, sep, perPage)
}
// paginatedArrayReader wraps a Reader to omit the opening and/or the closing square bracket of a
// JSON array in order to apply pagination context between multiple API requests.
type paginatedArrayReader struct {
io.Reader
isFirstPage bool
isLastPage bool
isSubsequentRead bool
cachedByte byte
}
func (r *paginatedArrayReader) Read(p []byte) (int, error) {
var n int
var err error
if r.cachedByte != 0 && len(p) > 0 {
p[0] = r.cachedByte
n, err = r.Reader.Read(p[1:])
n += 1
r.cachedByte = 0
} else {
n, err = r.Reader.Read(p)
}
if !r.isSubsequentRead && !r.isFirstPage && n > 0 && p[0] == '[' {
if n > 1 && p[1] == ']' {
// empty array case
p[0] = ' '
} else {
// avoid starting a new array and continue with a comma instead
p[0] = ','
}
}
if !r.isLastPage && n > 0 && p[n-1] == ']' {
// avoid closing off an array in case we determine we are at EOF
r.cachedByte = p[n-1]
n -= 1
}
r.isSubsequentRead = true
return n, err
}

View file

@ -14,7 +14,7 @@ import (
"github.com/cli/cli/v2/pkg/cmd/auth/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
ghAuth "github.com/cli/go-gh/pkg/auth"
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
"github.com/spf13/cobra"
)
@ -151,6 +151,11 @@ func loginRun(opts *LoginOptions) error {
}
}
// The go-gh Config object currently does not support case-insensitive lookups for host names,
// so normalize the host name case here before performing any lookups with it or persisting it.
// https://github.com/cli/go-gh/pull/105
hostname = strings.ToLower(hostname)
if src, writeable := shared.AuthTokenWriteable(authCfg, hostname); !writeable {
fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", src)
fmt.Fprint(opts.IO.ErrOut, "To have GitHub CLI store credentials instead, first clear the value from the environment.\n")

View file

@ -12,6 +12,7 @@ import (
"github.com/cli/cli/v2/pkg/cmd/auth/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/set"
"github.com/spf13/cobra"
)
@ -24,9 +25,11 @@ type RefreshOptions struct {
MainExecutable string
Hostname string
Scopes []string
AuthFlow func(*config.AuthConfig, *iostreams.IOStreams, string, []string, bool, bool) error
Hostname string
Scopes []string
RemoveScopes []string
ResetScopes bool
AuthFlow func(*config.AuthConfig, *iostreams.IOStreams, string, []string, bool, bool) error
Interactive bool
InsecureStorage bool
@ -62,16 +65,25 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
your gh credentials to have. If no scopes are provided, the command
maintains previously added scopes.
The command can only add additional scopes, but not remove previously
added ones. To reset scopes to the default minimum set of scopes, you
will need to create new credentials using the auth login command.
The --remove-scopes flag accepts a comma separated list of scopes you
want to remove from your gh credentials. Scope removal is idempotent.
The minimum set of scopes ("repo", "read:org" and "gist") cannot be removed.
The --reset-scopes flag resets the scopes for your gh credentials to
the default set of scopes for your auth flow.
`),
Example: heredoc.Doc(`
$ gh auth refresh --scopes write:org,read:public_key
# => open a browser to add write:org and read:public_key scopes for use with gh api
# => open a browser to add write:org and read:public_key scopes
$ gh auth refresh
# => open a browser to ensure your authentication credentials have the correct minimum scopes
$ gh auth refresh --remove-scopes delete_repo
# => open a browser to idempotently remove the delete_repo scope
$ gh auth refresh --reset-scopes
# => open a browser to re-authenticate with the default minimum scopes
`),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Interactive = opts.IO.CanPrompt()
@ -90,6 +102,8 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The GitHub host to use for authentication")
cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes for gh to have")
cmd.Flags().StringSliceVarP(&opts.RemoveScopes, "remove-scopes", "r", nil, "Authentication scopes to remove from gh")
cmd.Flags().BoolVar(&opts.ResetScopes, "reset-scopes", false, "Reset authentication scopes to the default minimum set of scopes")
// secure storage became the default on 2023/4/04; this flag is left as a no-op for backwards compatibility
var secureStorage bool
cmd.Flags().BoolVar(&secureStorage, "secure-storage", false, "Save authentication credentials in secure credential store")
@ -143,13 +157,16 @@ func refreshRun(opts *RefreshOptions) error {
return cmdutil.SilentError
}
var additionalScopes []string
if oldToken, _ := authCfg.Token(hostname); oldToken != "" {
if oldScopes, err := shared.GetScopes(opts.HttpClient, hostname, oldToken); err == nil {
for _, s := range strings.Split(oldScopes, ",") {
s = strings.TrimSpace(s)
if s != "" {
additionalScopes = append(additionalScopes, s)
additionalScopes := set.NewStringSet()
if !opts.ResetScopes {
if oldToken, _ := authCfg.Token(hostname); oldToken != "" {
if oldScopes, err := shared.GetScopes(opts.HttpClient, hostname, oldToken); err == nil {
for _, s := range strings.Split(oldScopes, ",") {
s = strings.TrimSpace(s)
if s != "" {
additionalScopes.Add(s)
}
}
}
}
@ -165,10 +182,14 @@ func refreshRun(opts *RefreshOptions) error {
if err := credentialFlow.Prompt(hostname); err != nil {
return err
}
additionalScopes = append(additionalScopes, credentialFlow.Scopes()...)
additionalScopes.AddValues(credentialFlow.Scopes())
}
if err := opts.AuthFlow(authCfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...), opts.Interactive, !opts.InsecureStorage); err != nil {
additionalScopes.AddValues(opts.Scopes)
additionalScopes.RemoveValues(opts.RemoveScopes)
if err := opts.AuthFlow(authCfg, opts.IO, hostname, additionalScopes.ToSlice(), opts.Interactive, !opts.InsecureStorage); err != nil {
return err
}

View file

@ -16,8 +16,6 @@ import (
"github.com/stretchr/testify/assert"
)
// TODO prompt cfg test
func Test_NewCmdRefresh(t *testing.T) {
tests := []struct {
name string
@ -99,6 +97,38 @@ func Test_NewCmdRefresh(t *testing.T) {
InsecureStorage: true,
},
},
{
name: "reset scopes",
tty: true,
cli: "--reset-scopes",
wants: RefreshOptions{
ResetScopes: true,
},
},
{
name: "remove scope",
tty: true,
cli: "--remove-scopes read:public_key",
wants: RefreshOptions{
RemoveScopes: []string{"read:public_key"},
},
},
{
name: "remove multiple scopes",
tty: true,
cli: "--remove-scopes workflow,read:public_key",
wants: RefreshOptions{
RemoveScopes: []string{"workflow", "read:public_key"},
},
},
{
name: "remove scope shorthand",
tty: true,
cli: "-r read:public_key",
wants: RefreshOptions{
RemoveScopes: []string{"read:public_key"},
},
},
}
for _, tt := range tests {
@ -185,7 +215,7 @@ func Test_refreshRun(t *testing.T) {
},
wantAuthArgs: authArgs{
hostname: "obed.morton",
scopes: nil,
scopes: []string{},
secureStorage: true,
},
},
@ -199,7 +229,7 @@ func Test_refreshRun(t *testing.T) {
},
wantAuthArgs: authArgs{
hostname: "github.com",
scopes: nil,
scopes: []string{},
secureStorage: true,
},
},
@ -219,7 +249,7 @@ func Test_refreshRun(t *testing.T) {
},
wantAuthArgs: authArgs{
hostname: "github.com",
scopes: nil,
scopes: []string{},
secureStorage: true,
},
},
@ -248,7 +278,7 @@ func Test_refreshRun(t *testing.T) {
},
wantAuthArgs: authArgs{
hostname: "github.com",
scopes: []string{"repo:invite", "public_key:read", "delete_repo", "codespace"},
scopes: []string{"delete_repo", "codespace", "repo:invite", "public_key:read"},
secureStorage: true,
},
},
@ -262,7 +292,7 @@ func Test_refreshRun(t *testing.T) {
},
wantAuthArgs: authArgs{
hostname: "obed.morton",
scopes: nil,
scopes: []string{},
secureStorage: true,
},
},
@ -277,7 +307,101 @@ func Test_refreshRun(t *testing.T) {
},
wantAuthArgs: authArgs{
hostname: "obed.morton",
scopes: nil,
scopes: []string{},
},
},
{
name: "reset scopes",
cfgHosts: []string{
"github.com",
},
oldScopes: "delete_repo, codespace",
opts: &RefreshOptions{
Hostname: "github.com",
ResetScopes: true,
},
wantAuthArgs: authArgs{
hostname: "github.com",
scopes: []string{},
secureStorage: true,
},
},
{
name: "reset scopes and add some scopes",
cfgHosts: []string{
"github.com",
},
oldScopes: "repo:invite, delete_repo, codespace",
opts: &RefreshOptions{
Scopes: []string{"public_key:read", "workflow"},
ResetScopes: true,
},
wantAuthArgs: authArgs{
hostname: "github.com",
scopes: []string{"public_key:read", "workflow"},
secureStorage: true,
},
},
{
name: "remove scopes",
cfgHosts: []string{
"github.com",
},
oldScopes: "delete_repo, codespace, repo:invite, public_key:read",
opts: &RefreshOptions{
Hostname: "github.com",
RemoveScopes: []string{"delete_repo", "repo:invite"},
},
wantAuthArgs: authArgs{
hostname: "github.com",
scopes: []string{"codespace", "public_key:read"},
secureStorage: true,
},
},
{
name: "remove scope but no old scope",
cfgHosts: []string{
"github.com",
},
opts: &RefreshOptions{
Hostname: "github.com",
RemoveScopes: []string{"delete_repo"},
},
wantAuthArgs: authArgs{
hostname: "github.com",
scopes: []string{},
secureStorage: true,
},
},
{
name: "remove and add scopes at the same time",
cfgHosts: []string{
"github.com",
},
oldScopes: "repo:invite, delete_repo, codespace",
opts: &RefreshOptions{
Scopes: []string{"repo:invite", "public_key:read", "workflow"},
RemoveScopes: []string{"codespace", "repo:invite", "workflow"},
},
wantAuthArgs: authArgs{
hostname: "github.com",
scopes: []string{"delete_repo", "public_key:read"},
secureStorage: true,
},
},
{
name: "remove scopes that don't exist",
cfgHosts: []string{
"github.com",
},
oldScopes: "repo:invite, delete_repo, codespace",
opts: &RefreshOptions{
RemoveScopes: []string{"codespace", "repo:invite", "public_key:read"},
},
wantAuthArgs: authArgs{
hostname: "github.com",
scopes: []string{"delete_repo"},
secureStorage: true,
},
},
}

View file

@ -66,7 +66,7 @@ func statusRun(opts *StatusOptions) error {
// TODO check tty
stderr := opts.IO.ErrOut
stdout := opts.IO.Out
cs := opts.IO.ColorScheme()
statusInfo := map[string][]string{}
@ -166,13 +166,22 @@ func statusRun(opts *StatusOptions) error {
if !ok {
continue
}
if prevEntry {
if prevEntry && failed {
fmt.Fprint(stderr, "\n")
} else if prevEntry && !failed {
fmt.Fprint(stdout, "\n")
}
prevEntry = true
fmt.Fprintf(stderr, "%s\n", cs.Bold(hostname))
for _, line := range lines {
fmt.Fprintf(stderr, " %s\n", line)
if failed {
fmt.Fprintf(stderr, "%s\n", cs.Bold(hostname))
for _, line := range lines {
fmt.Fprintf(stderr, " %s\n", line)
}
} else {
fmt.Fprintf(stdout, "%s\n", cs.Bold(hostname))
for _, line := range lines {
fmt.Fprintf(stdout, " %s\n", line)
}
}
}

View file

@ -76,12 +76,13 @@ func Test_statusRun(t *testing.T) {
readConfigs := config.StubWriteConfig(t)
tests := []struct {
name string
opts *StatusOptions
httpStubs func(*httpmock.Registry)
cfgStubs func(*config.ConfigMock)
wantErr string
wantOut string
name string
opts *StatusOptions
httpStubs func(*httpmock.Registry)
cfgStubs func(*config.ConfigMock)
wantErr string
wantOut string
wantErrOut string
}{
{
name: "hostname set",
@ -126,7 +127,7 @@ func Test_statusRun(t *testing.T) {
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
},
wantErr: "SilentError",
wantOut: heredoc.Doc(`
wantErrOut: heredoc.Doc(`
joel.miller
X joel.miller: the token in GH_CONFIG_DIR/hosts.yml is missing required scope 'read:org'
- To request missing scopes, run: gh auth refresh -h joel.miller
@ -156,7 +157,7 @@ func Test_statusRun(t *testing.T) {
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
},
wantErr: "SilentError",
wantOut: heredoc.Doc(`
wantErrOut: heredoc.Doc(`
joel.miller
X joel.miller: authentication failed
- The joel.miller token in GH_CONFIG_DIR/hosts.yml is no longer valid.
@ -298,9 +299,9 @@ func Test_statusRun(t *testing.T) {
cfgStubs: func(c *config.ConfigMock) {
c.Set("github.com", "oauth_token", "abc123")
},
httpStubs: func(reg *httpmock.Registry) {},
wantErr: "SilentError",
wantOut: "Hostname \"github.example.com\" not found among authenticated GitHub hosts\n",
httpStubs: func(reg *httpmock.Registry) {},
wantErr: "SilentError",
wantErrOut: "Hostname \"github.example.com\" not found among authenticated GitHub hosts\n",
},
}
@ -310,13 +311,12 @@ func Test_statusRun(t *testing.T) {
tt.opts = &StatusOptions{}
}
ios, _, _, stderr := iostreams.Test()
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdinTTY(true)
ios.SetStderrTTY(true)
ios.SetStdoutTTY(true)
tt.opts.IO = ios
cfg := config.NewFromString("")
if tt.cfgStubs != nil {
tt.cfgStubs(cfg)
@ -340,8 +340,10 @@ func Test_statusRun(t *testing.T) {
} else {
assert.NoError(t, err)
}
output := strings.ReplaceAll(stdout.String(), config.ConfigDir()+string(filepath.Separator), "GH_CONFIG_DIR/")
errorOutput := strings.ReplaceAll(stderr.String(), config.ConfigDir()+string(filepath.Separator), "GH_CONFIG_DIR/")
output := strings.ReplaceAll(stderr.String(), config.ConfigDir()+string(filepath.Separator), "GH_CONFIG_DIR/")
assert.Equal(t, tt.wantErrOut, errorOutput)
assert.Equal(t, tt.wantOut, output)
mainBuf := bytes.Buffer{}

View file

@ -163,14 +163,12 @@ func runBrowse(opts *BrowseOptions) error {
return fmt.Errorf("unable to determine base repository: %w", err)
}
if opts.Commit != "" {
if opts.Commit == emptyCommitFlag {
commit, err := opts.GitClient.LastCommit()
if err != nil {
return err
}
opts.Commit = commit.Sha
if opts.Commit != "" && opts.Commit == emptyCommitFlag {
commit, err := opts.GitClient.LastCommit()
if err != nil {
return err
}
opts.Commit = commit.Sha
}
section, err := parseSection(baseRepo, opts)

View file

@ -370,7 +370,7 @@ func Test_runBrowse(t *testing.T) {
opts: BrowseOptions{
SelectorArg: "chocolate-pecan-pie.txt",
},
baseRepo: ghrepo.New("andrewhsu", "recipies"),
baseRepo: ghrepo.New("andrewhsu", "recipes"),
defaultBranch: "",
wantsErr: true,
},

View file

@ -15,6 +15,7 @@ type CodespaceSelector struct {
repoName string
codespaceName string
repoOwner string
}
var errNoFilteredCodespaces = errors.New("you have no codespaces meeting the filter criteria")
@ -25,8 +26,10 @@ func AddCodespaceSelector(cmd *cobra.Command, api apiClient) *CodespaceSelector
cmd.PersistentFlags().StringVarP(&cs.codespaceName, "codespace", "c", "", "Name of the codespace")
cmd.PersistentFlags().StringVarP(&cs.repoName, "repo", "R", "", "Filter codespace selection by repository name (user/repo)")
cmd.PersistentFlags().StringVar(&cs.repoOwner, "repo-owner", "", "Filter codespace selection by repository owner (username or org)")
cmd.MarkFlagsMutuallyExclusive("codespace", "repo")
cmd.MarkFlagsMutuallyExclusive("codespace", "repo-owner")
return cs
}
@ -102,6 +105,10 @@ func (cs *CodespaceSelector) fetchCodespaces(ctx context.Context) (codespaces []
codespaces = filteredCodespaces
}
if cs.repoOwner != "" {
codespaces = filterCodespacesByRepoOwner(codespaces, cs.repoOwner)
}
if len(codespaces) == 0 {
return nil, errNoFilteredCodespaces
}

View file

@ -48,68 +48,101 @@ func TestSelectNameWithCodespaceName(t *testing.T) {
func TestFetchCodespaces(t *testing.T) {
var (
repoA1 = &api.Codespace{Name: "1", Repository: api.Repository{FullName: "mock/A"}}
repoA2 = &api.Codespace{Name: "2", Repository: api.Repository{FullName: "mock/A"}}
octocatOwner = api.RepositoryOwner{Login: "octocat"}
cliOwner = api.RepositoryOwner{Login: "cli"}
octocatA = &api.Codespace{
Name: "1",
Repository: api.Repository{FullName: "octocat/A", Owner: octocatOwner},
}
repoB1 = &api.Codespace{Name: "1", Repository: api.Repository{FullName: "mock/B"}}
octocatA2 = &api.Codespace{
Name: "2",
Repository: api.Repository{FullName: "octocat/A", Owner: octocatOwner},
}
cliA = &api.Codespace{
Name: "3",
Repository: api.Repository{FullName: "cli/A", Owner: cliOwner},
}
octocatB = &api.Codespace{
Name: "4",
Repository: api.Repository{FullName: "octocat/B", Owner: octocatOwner},
}
)
tests := []struct {
tName string
apiCodespaces []*api.Codespace
codespaceName string
repoName string
repoOwner string
wantCodespaces []*api.Codespace
wantErr error
}{
// Empty case
{
"empty", nil, "", nil, errNoCodespaces,
tName: "empty",
apiCodespaces: nil,
wantCodespaces: nil,
wantErr: errNoCodespaces,
},
// Tests with no filtering
{
"no filtering, single codespace",
[]*api.Codespace{repoA1},
"",
[]*api.Codespace{repoA1},
nil,
tName: "no filtering, single codespaces",
apiCodespaces: []*api.Codespace{octocatA},
wantCodespaces: []*api.Codespace{octocatA},
wantErr: nil,
},
{
"no filtering, multiple codespaces",
[]*api.Codespace{repoA1, repoA2, repoB1},
"",
[]*api.Codespace{repoA1, repoA2, repoB1},
nil,
tName: "no filtering, multiple codespace",
apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB},
wantCodespaces: []*api.Codespace{octocatA, cliA, octocatB},
},
// Test repo filtering
{
"repo filtering, single codespace",
[]*api.Codespace{repoA1},
"mock/A",
[]*api.Codespace{repoA1},
nil,
tName: "repo name filtering, single codespace",
apiCodespaces: []*api.Codespace{octocatA},
repoName: "octocat/A",
wantCodespaces: []*api.Codespace{octocatA},
wantErr: nil,
},
{
"repo filtering, multiple codespaces",
[]*api.Codespace{repoA1, repoA2, repoB1},
"mock/A",
[]*api.Codespace{repoA1, repoA2},
nil,
tName: "repo name filtering, multiple codespace",
apiCodespaces: []*api.Codespace{octocatA, octocatA2, cliA, octocatB},
repoName: "octocat/A",
wantCodespaces: []*api.Codespace{octocatA, octocatA2},
wantErr: nil,
},
{
"repo filtering, multiple codespaces 2",
[]*api.Codespace{repoA1, repoA2, repoB1},
"mock/B",
[]*api.Codespace{repoB1},
nil,
tName: "repo name filtering, multiple codespace 2",
apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB},
repoName: "octocat/B",
wantCodespaces: []*api.Codespace{octocatB},
wantErr: nil,
},
{
"repo filtering, no matches",
[]*api.Codespace{repoA1, repoA2, repoB1},
"mock/C",
nil,
errNoFilteredCodespaces,
tName: "repo name filtering, no matches",
apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB},
repoName: "Unknown/unknown",
wantCodespaces: nil,
wantErr: errNoFilteredCodespaces,
},
{
tName: "repo filtering, match with repo owner",
apiCodespaces: []*api.Codespace{octocatA, octocatA2, cliA, octocatB},
repoOwner: "octocat",
wantCodespaces: []*api.Codespace{octocatA, octocatA2, octocatB},
wantErr: nil,
},
{
tName: "repo filtering, no match with repo owner",
apiCodespaces: []*api.Codespace{octocatA, cliA, octocatB},
repoOwner: "unknown",
wantCodespaces: []*api.Codespace{},
wantErr: errNoFilteredCodespaces,
},
}
@ -121,7 +154,11 @@ func TestFetchCodespaces(t *testing.T) {
},
}
cs := &CodespaceSelector{api: api, repoName: tt.repoName}
cs := &CodespaceSelector{
api: api,
repoName: tt.repoName,
repoOwner: tt.repoOwner,
}
codespaces, err := cs.fetchCodespaces(context.Background())

View file

@ -10,6 +10,7 @@ import (
"log"
"os"
"sort"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
@ -266,3 +267,14 @@ func addDeprecatedRepoShorthand(cmd *cobra.Command, target *string) error {
return nil
}
// filterCodespacesByRepoOwner filters a list of codespaces by the owner of the repository.
func filterCodespacesByRepoOwner(codespaces []*api.Codespace, repoOwner string) []*api.Codespace {
filtered := make([]*api.Codespace, 0, len(codespaces))
for _, c := range codespaces {
if strings.EqualFold(c.Repository.Owner.Login, repoOwner) {
filtered = append(filtered, c)
}
}
return filtered
}

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strings"
"sync/atomic"
"time"
"github.com/AlecAivazis/survey/v2"
@ -23,6 +24,7 @@ type deleteOptions struct {
keepDays uint16
orgName string
userName string
repoOwner string
isInteractive bool
now func() time.Time
@ -60,10 +62,12 @@ func newDeleteCmd(app *App) *cobra.Command {
// After the admin subcommand is added (see https://github.com/cli/cli/pull/6944#issuecomment-1419553639) we can revisit this.
opts.codespaceName = selector.codespaceName
opts.repoFilter = selector.repoName
opts.repoOwner = selector.repoOwner
if opts.deleteAll && opts.repoFilter != "" {
return cmdutil.FlagErrorf("both `--all` and `--repo` is not supported")
}
if opts.orgName != "" && opts.codespaceName != "" && opts.userName == "" {
return cmdutil.FlagErrorf("using `--org` with `--codespace` requires `--user`")
}
@ -99,6 +103,9 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
userName = currentUser.Login
}
codespaces, fetchErr = a.apiClient.ListCodespaces(ctx, api.ListCodespacesOptions{OrgName: opts.orgName, UserName: userName})
if opts.repoOwner != "" {
codespaces = filterCodespacesByRepoOwner(codespaces, opts.repoOwner)
}
return
})
if err != nil {
@ -139,6 +146,7 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
if opts.repoFilter != "" && !strings.EqualFold(c.Repository.FullName, opts.repoFilter) {
continue
}
if opts.keepDays > 0 {
t, err := time.Parse(time.RFC3339, c.LastUsedAt)
if err != nil {
@ -169,7 +177,8 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
progressLabel = "Deleting codespaces"
}
return a.RunWithProgress(progressLabel, func() error {
var deletedCodespaces uint32
err = a.RunWithProgress(progressLabel, func() error {
var g errgroup.Group
for _, c := range codespacesToDelete {
codespaceName := c.Name
@ -178,15 +187,23 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
a.errLogger.Printf("error deleting codespace %q: %v\n", codespaceName, err)
return err
}
atomic.AddUint32(&deletedCodespaces, 1)
return nil
})
}
if err := g.Wait(); err != nil {
return errors.New("some codespaces failed to delete")
return fmt.Errorf("%d codespace(s) failed to delete", len(codespacesToDelete)-int(deletedCodespaces))
}
return nil
})
if a.io.IsStdoutTTY() && deletedCodespaces > 0 {
successMsg := fmt.Sprintf("%d codespace(s) deleted successfully\n", deletedCodespaces)
fmt.Fprint(a.io.ErrOut, successMsg)
}
return err
}
func confirmDeletion(p prompter, apiCodespace *api.Codespace, isInteractive bool) (bool, error) {

View file

@ -26,7 +26,7 @@ func TestDelete(t *testing.T) {
codespaces []*api.Codespace
confirms map[string]bool
deleteErr error
wantErr bool
wantErr string
wantDeleted []string
wantStdout string
wantStderr string
@ -42,7 +42,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"hubot-robawt-abc"},
wantStdout: "",
wantStderr: "1 codespace(s) deleted successfully\n",
},
{
name: "by repo",
@ -70,7 +70,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"monalisa-spoonknife-123", "monalisa-spoonknife-c4f3"},
wantStdout: "",
wantStderr: "2 codespace(s) deleted successfully\n",
},
{
name: "unused",
@ -93,7 +93,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"},
wantStdout: "",
wantStderr: "2 codespace(s) deleted successfully\n",
},
{
name: "deletion failed",
@ -109,12 +109,13 @@ func TestDelete(t *testing.T) {
},
},
deleteErr: errors.New("aborted by test"),
wantErr: true,
wantErr: "2 codespace(s) failed to delete",
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-123"},
wantStderr: heredoc.Doc(`
error deleting codespace "hubot-robawt-abc": aborted by test
error deleting codespace "monalisa-spoonknife-123": aborted by test
`),
wantStdout: "",
},
{
name: "with confirm",
@ -149,7 +150,7 @@ func TestDelete(t *testing.T) {
"Codespace hubot-robawt-abc has unsaved changes. OK to delete?": true,
},
wantDeleted: []string{"hubot-robawt-abc", "monalisa-spoonknife-c4f3"},
wantStdout: "",
wantStderr: "2 codespace(s) deleted successfully\n",
},
{
name: "deletion for org codespace by admin succeeds",
@ -174,7 +175,7 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"monalisa-spoonknife-123"},
wantStdout: "",
wantStderr: "1 codespace(s) deleted successfully\n",
},
{
name: "deletion for org codespace by admin fails for codespace not found",
@ -200,7 +201,8 @@ func TestDelete(t *testing.T) {
},
wantDeleted: []string{},
wantStdout: "",
wantErr: true,
wantErr: "error fetching codespace information: " +
"codespace not found for user johnDoe with name monalisa-spoonknife-123",
},
{
name: "deletion for org codespace succeeds without username",
@ -215,6 +217,45 @@ func TestDelete(t *testing.T) {
},
},
wantDeleted: []string{"monalisa-spoonknife-123"},
wantStderr: "1 codespace(s) deleted successfully\n",
},
{
name: "by repo owner",
opts: deleteOptions{
deleteAll: true,
repoOwner: "octocat",
},
codespaces: []*api.Codespace{
{
Name: "octocat-spoonknife-123",
Repository: api.Repository{
FullName: "octocat/Spoon-Knife",
Owner: api.RepositoryOwner{
Login: "octocat",
},
},
},
{
Name: "cli-robawt-abc",
Repository: api.Repository{
FullName: "cli/ROBAWT",
Owner: api.RepositoryOwner{
Login: "cli",
},
},
},
{
Name: "octocat-spoonknife-c4f3",
Repository: api.Repository{
FullName: "octocat/Spoon-Knife",
Owner: api.RepositoryOwner{
Login: "octocat",
},
},
},
},
wantDeleted: []string{"octocat-spoonknife-123", "octocat-spoonknife-c4f3"},
wantStderr: "2 codespace(s) deleted successfully\n",
wantStdout: "",
},
}
@ -268,8 +309,8 @@ func TestDelete(t *testing.T) {
ios.SetStdoutTTY(true)
app := NewApp(ios, nil, apiMock, nil, nil)
err := app.Delete(context.Background(), opts)
if (err != nil) != tt.wantErr {
t.Errorf("delete() error = %v, wantErr %v", err, tt.wantErr)
if (err != nil) && tt.wantErr != err.Error() {
t.Errorf("delete() error = %v, wantErr = %v", err, tt.wantErr)
}
for _, listArgs := range apiMock.ListCodespacesCalls() {
if listArgs.Opts.OrgName != "" && listArgs.Opts.UserName == "" {

View file

@ -67,7 +67,7 @@ func (a *App) Jupyter(ctx context.Context, selector *CodespaceSelector) (err err
}
// Pass 0 to pick a random port
listen, _, err := codespaces.ListenTCP(0)
listen, _, err := codespaces.ListenTCP(0, false)
if err != nil {
return err
}

View file

@ -70,7 +70,7 @@ func newListCmd(app *App) *cobra.Command {
listCmd.Flags().StringVarP(&opts.orgName, "org", "o", "", "The `login` handle of the organization to list codespaces for (admin-only)")
listCmd.Flags().StringVarP(&opts.userName, "user", "u", "", "The `username` to list codespaces for (used with --org)")
cmdutil.AddJSONFlags(listCmd, &exporter, api.CodespaceFields)
cmdutil.AddJSONFlags(listCmd, &exporter, api.ListCodespaceFields)
listCmd.Flags().BoolVarP(&opts.useWeb, "web", "w", false, "List codespaces in the web browser, cannot be used with --user or --org")

View file

@ -49,7 +49,7 @@ func (a *App) Logs(ctx context.Context, selector *CodespaceSelector, follow bool
defer safeClose(session, &err)
// Ensure local port is listening before client (getPostCreateOutput) connects.
listen, localPort, err := codespaces.ListenTCP(0)
listen, localPort, err := codespaces.ListenTCP(0, false)
if err != nil {
return err
}

View file

@ -214,7 +214,7 @@ func getDevContainer(ctx context.Context, apiClient apiClient, codespace *api.Co
var container devContainer
if err := json.Unmarshal(convertedJSON, &container); err != nil {
ch <- devContainerResult{nil, fmt.Errorf("error unmarshaling: %w", err)}
ch <- devContainerResult{nil, fmt.Errorf("error unmarshalling: %w", err)}
return
}
@ -379,7 +379,7 @@ func (a *App) ForwardPorts(ctx context.Context, selector *CodespaceSelector, por
for _, pair := range portPairs {
pair := pair
group.Go(func() error {
listen, _, err := codespaces.ListenTCP(pair.local)
listen, _, err := codespaces.ListenTCP(pair.local, true)
if err != nil {
return err
}

View file

@ -225,7 +225,7 @@ func TestPendingOperationDisallowsListPorts(t *testing.T) {
}
}
func TestPendingOperationDisallowsUpdatePortVisability(t *testing.T) {
func TestPendingOperationDisallowsUpdatePortVisibility(t *testing.T) {
app := testingPortsApp()
selector := &CodespaceSelector{api: app.apiClient, codespaceName: "disabledCodespace"}

View file

@ -16,6 +16,7 @@ func NewRootCmd(app *App) *cobra.Command {
root.AddCommand(newDeleteCmd(app))
root.AddCommand(newJupyterCmd(app))
root.AddCommand(newListCmd(app))
root.AddCommand(newViewCmd(app))
root.AddCommand(newLogsCmd(app))
root.AddCommand(newPortsCmd(app))
root.AddCommand(newSSHCmd(app))

View file

@ -36,14 +36,15 @@ const automaticPrivateKeyName = "codespaces.auto"
var errKeyFileNotFound = errors.New("SSH key file does not exist")
type sshOptions struct {
selector *CodespaceSelector
profile string
serverPort int
debug bool
debugFile string
stdio bool
config bool
scpArgs []string // scp arguments, for 'cs cp' (nil for 'cs ssh')
selector *CodespaceSelector
profile string
serverPort int
printConnDetails bool
debug bool
debugFile string
stdio bool
config bool
scpArgs []string // scp arguments, for 'cs cp' (nil for 'cs ssh')
}
func newSSHCmd(app *App) *cobra.Command {
@ -56,8 +57,14 @@ func newSSHCmd(app *App) *cobra.Command {
The 'ssh' command is used to SSH into a codespace. In its simplest form, you can
run 'gh cs ssh', select a codespace interactively, and connect.
By default, the 'ssh' command will create a public/private ssh key pair to
authenticate with the codespace inside the ~/.ssh directory.
The 'ssh' command will automatically create a public/private ssh key pair in the
~/.ssh directory if you do not have an existing valid key pair. When selecting the
key pair to use, the preferred order is:
1. Key specified by -i in <ssh-flags>
2. Automatic key, if it already exists
3. First valid key pair in ssh config (according to ssh -G)
4. Automatic key, newly created
The 'ssh' command also supports deeper integration with OpenSSH using a '--config'
option that generates per-codespace ssh configuration in OpenSSH format.
@ -111,6 +118,9 @@ func newSSHCmd(app *App) *cobra.Command {
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flag("server-port").Changed {
opts.printConnDetails = true
}
if opts.config {
return app.printOpenSSHConfig(cmd.Context(), opts)
} else {
@ -200,12 +210,11 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e
}
localSSHServerPort := opts.serverPort
usingCustomPort := localSSHServerPort != 0 // suppress log of command line in Shell
// Ensure local port is listening before client (Shell) connects.
// Unless the user specifies a server port, localSSHServerPort is 0
// and thus the client will pick a random port.
listen, localSSHServerPort, err := codespaces.ListenTCP(localSSHServerPort)
listen, localSSHServerPort, err := codespaces.ListenTCP(localSSHServerPort, false)
if err != nil {
return err
}
@ -229,7 +238,9 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e
// args is the correct variable to use here, we just use scpArgs as the check for which command to run
err = codespaces.Copy(ctx, args, localSSHServerPort, connectDestination)
} else {
err = codespaces.Shell(ctx, a.errLogger, args, localSSHServerPort, connectDestination, usingCustomPort)
err = codespaces.Shell(
ctx, a.errLogger, args, localSSHServerPort, connectDestination, opts.printConnDetails,
)
}
shellClosed <- err
}()

133
pkg/cmd/codespace/view.go Normal file
View file

@ -0,0 +1,133 @@
package codespace
import (
"context"
"fmt"
"os"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/internal/tableprinter"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
)
const (
minutesInDay = 1440
)
type viewOptions struct {
selector *CodespaceSelector
exporter cmdutil.Exporter
}
func newViewCmd(app *App) *cobra.Command {
opts := &viewOptions{}
viewCmd := &cobra.Command{
Use: "view",
Short: "View details about a codespace",
Example: heredoc.Doc(`
# select a codespace from a list of all codespaces you own
$ gh cs view
# view the details of a specific codespace
$ gh cs view -c codespace-name-12345
# view the list of all available fields for a codespace
$ gh cs view --json
# view specific fields for a codespace
$ gh cs view --json displayName,machineDisplayName,state
`),
Args: noArgsConstraint,
RunE: func(cmd *cobra.Command, args []string) error {
return app.ViewCodespace(cmd.Context(), opts)
},
}
opts.selector = AddCodespaceSelector(viewCmd, app.apiClient)
cmdutil.AddJSONFlags(viewCmd, &opts.exporter, api.ViewCodespaceFields)
return viewCmd
}
func (a *App) ViewCodespace(ctx context.Context, opts *viewOptions) error {
// If we are in a codespace and a codespace name wasn't provided, show the details for the codespace we are connected to
if (os.Getenv("CODESPACES") == "true") && opts.selector.codespaceName == "" {
codespaceName := os.Getenv("CODESPACE_NAME")
opts.selector.codespaceName = codespaceName
}
selectedCodespace, err := opts.selector.Select(ctx)
if err != nil {
return err
}
if err := a.io.StartPager(); err != nil {
a.errLogger.Printf("error starting pager: %v", err)
}
defer a.io.StopPager()
if opts.exporter != nil {
return opts.exporter.Write(a.io, selectedCodespace)
}
tp := tableprinter.New(a.io)
c := codespace{selectedCodespace}
formattedName := formatNameForVSCSTarget(c.Name, c.VSCSTarget)
// Create an array of fields to display in the table with their values
fields := []struct {
name string
value string
}{
{"Name", formattedName},
{"State", c.State},
{"Repository", c.Repository.FullName},
{"Git Status", formatGitStatus(c)},
{"Devcontainer Path", c.DevContainerPath},
{"Machine Display Name", c.Machine.DisplayName},
{"Idle Timeout", fmt.Sprintf("%d minutes", c.IdleTimeoutMinutes)},
{"Created At", c.CreatedAt},
{"Retention Period", formatRetentionPeriodDays(c)},
}
for _, field := range fields {
// Don't display the field if it is empty and we are printing to a TTY
if !a.io.IsStdoutTTY() || field.value != "" {
tp.AddField(field.name)
tp.AddField(field.value)
tp.EndRow()
}
}
err = tp.Render()
if err != nil {
return err
}
return nil
}
func formatGitStatus(codespace codespace) string {
branchWithGitStatus := codespace.branchWithGitStatus()
// Format the commits ahead/behind with proper pluralization
commitsAhead := text.Pluralize(codespace.GitStatus.Ahead, "commit")
commitsBehind := text.Pluralize(codespace.GitStatus.Behind, "commit")
return fmt.Sprintf("%s - %s ahead, %s behind", branchWithGitStatus, commitsAhead, commitsBehind)
}
func formatRetentionPeriodDays(codespace codespace) string {
days := codespace.RetentionPeriodMinutes / minutesInDay
// Don't display the retention period if it is 0 days
if days == 0 {
return ""
} else if days == 1 {
return "1 day"
}
return fmt.Sprintf("%d days", days)
}

View file

@ -0,0 +1,131 @@
package codespace
import (
"context"
"fmt"
"testing"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/iostreams"
)
func Test_NewCmdView(t *testing.T) {
tests := []struct {
tName string
codespaceName string
opts *viewOptions
cliArgs []string
wantErr bool
wantStdout string
errMsg string
}{
{
tName: "selector throws because no terminal found",
opts: &viewOptions{},
wantErr: true,
errMsg: "choosing codespace: error getting answers: no terminal",
},
{
tName: "command fails because provided codespace doesn't exist",
codespaceName: "i-dont-exist",
opts: &viewOptions{},
wantErr: true,
errMsg: "getting full codespace details: codespace not found",
},
{
tName: "command succeeds because codespace exists (no details)",
codespaceName: "monalisa-cli-cli-abcdef",
opts: &viewOptions{},
wantErr: false,
wantStdout: "Name\tmonalisa-cli-cli-abcdef\nState\t\nRepository\t\nGit Status\t - 0 commits ahead, 0 commits behind\nDevcontainer Path\t\nMachine Display Name\t\nIdle Timeout\t0 minutes\nCreated At\t\nRetention Period\t\n",
},
{
tName: "command succeeds because codespace exists (with details)",
codespaceName: "monalisa-cli-cli-hijklm",
opts: &viewOptions{},
wantErr: false,
wantStdout: "Name\tmonalisa-cli-cli-hijklm\nState\tAvailable\nRepository\tcli/cli\nGit Status\tmain* - 1 commit ahead, 2 commits behind\nDevcontainer Path\t.devcontainer/devcontainer.json\nMachine Display Name\tTest Display Name\nIdle Timeout\t30 minutes\nCreated At\t\nRetention Period\t1 day\n",
},
}
for _, tt := range tests {
t.Run(tt.tName, func(t *testing.T) {
ios, _, stdout, _ := iostreams.Test()
a := &App{
apiClient: testViewApiMock(),
io: ios,
}
selector := &CodespaceSelector{api: a.apiClient, codespaceName: tt.codespaceName}
tt.opts.selector = selector
var err error
if tt.cliArgs == nil {
if tt.opts.selector == nil {
t.Fatalf("selector must be set in opts if cliArgs are not provided")
}
err = a.ViewCodespace(context.Background(), tt.opts)
} else {
cmd := newViewCmd(a)
cmd.SilenceUsage = true
cmd.SilenceErrors = true
cmd.SetOut(ios.ErrOut)
cmd.SetErr(ios.ErrOut)
cmd.SetArgs(tt.cliArgs)
_, err = cmd.ExecuteC()
}
if tt.wantErr {
if err == nil {
t.Error("Edit() expected error, got nil")
} else if err.Error() != tt.errMsg {
t.Errorf("Edit() error = %q, want %q", err, tt.errMsg)
}
} else if err != nil {
t.Errorf("Edit() expected no error, got %v", err)
}
if out := stdout.String(); out != tt.wantStdout {
t.Errorf("stdout = %q, want %q", out, tt.wantStdout)
}
})
}
}
func testViewApiMock() *apiClientMock {
codespaceWithNoDetails := &api.Codespace{
Name: "monalisa-cli-cli-abcdef",
}
codespaceWithDetails := &api.Codespace{
Name: "monalisa-cli-cli-hijklm",
GitStatus: api.CodespaceGitStatus{
Ahead: 1,
Behind: 2,
Ref: "main",
HasUnpushedChanges: true,
HasUncommittedChanges: true,
},
IdleTimeoutMinutes: 30,
RetentionPeriodMinutes: 1440,
State: "Available",
Repository: api.Repository{FullName: "cli/cli"},
DevContainerPath: ".devcontainer/devcontainer.json",
Machine: api.CodespaceMachine{
DisplayName: "Test Display Name",
},
}
return &apiClientMock{
GetCodespaceFunc: func(_ context.Context, name string, _ bool) (*api.Codespace, error) {
if name == codespaceWithDetails.Name {
return codespaceWithDetails, nil
} else if name == codespaceWithNoDetails.Name {
return codespaceWithNoDetails, nil
}
return nil, fmt.Errorf("codespace not found")
},
ListCodespacesFunc: func(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) {
return []*api.Codespace{codespaceWithNoDetails, codespaceWithDetails}, nil
},
}
}

View file

@ -84,43 +84,43 @@ func Test_getExtensionRepos(t *testing.T) {
reg.Register(
httpmock.QueryMatcher("GET", "search/repositories", values),
httpmock.JSONResponse(search.RepositoriesResult{
IncompleteResults: false,
Items: []search.Repository{
{
FullName: "vilmibm/gh-screensaver",
Name: "gh-screensaver",
Description: "terminal animations",
Owner: search.User{
Login: "vilmibm",
httpmock.JSONResponse(map[string]interface{}{
"incomplete_results": false,
"total_count": 4,
"items": []interface{}{
map[string]interface{}{
"name": "gh-screensaver",
"full_name": "vilmibm/gh-screensaver",
"description": "terminal animations",
"owner": map[string]interface{}{
"login": "vilmibm",
},
},
{
FullName: "cli/gh-cool",
Name: "gh-cool",
Description: "it's just cool ok",
Owner: search.User{
Login: "cli",
map[string]interface{}{
"name": "gh-cool",
"full_name": "cli/gh-cool",
"description": "it's just cool ok",
"owner": map[string]interface{}{
"login": "cli",
},
},
{
FullName: "samcoe/gh-triage",
Name: "gh-triage",
Description: "helps with triage",
Owner: search.User{
Login: "samcoe",
map[string]interface{}{
"name": "gh-triage",
"full_name": "samcoe/gh-triage",
"description": "helps with triage",
"owner": map[string]interface{}{
"login": "samcoe",
},
},
{
FullName: "github/gh-gei",
Name: "gh-gei",
Description: "something something enterprise",
Owner: search.User{
Login: "github",
map[string]interface{}{
"name": "gh-gei",
"full_name": "github/gh-gei",
"description": "something something enterprise",
"owner": map[string]interface{}{
"login": "github",
},
},
},
Total: 4,
}),
)

View file

@ -643,10 +643,8 @@ func checkValidExtension(rootCmd *cobra.Command, m extensions.ExtensionManager,
}
commandName := strings.TrimPrefix(extName, "gh-")
if c, _, err := rootCmd.Traverse([]string{commandName}); err != nil {
return nil, err
} else if c != rootCmd {
return nil, fmt.Errorf("%q matches the name of a built-in command", commandName)
if c, _, _ := rootCmd.Find([]string{commandName}); c != rootCmd && c.GroupID != "extension" {
return nil, fmt.Errorf("%q matches the name of a built-in command or alias", commandName)
}
for _, ext := range m.List() {

View file

@ -19,7 +19,6 @@ import (
"github.com/cli/cli/v2/pkg/extensions"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/search"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
)
@ -74,7 +73,7 @@ func TestNewCmdExtension(t *testing.T) {
}
reg.Register(
httpmock.QueryMatcher("GET", "search/repositories", values),
httpmock.JSONResponse(searchResults()),
httpmock.JSONResponse(searchResults(4)),
)
},
isTTY: true,
@ -111,7 +110,7 @@ func TestNewCmdExtension(t *testing.T) {
}
reg.Register(
httpmock.QueryMatcher("GET", "search/repositories", values),
httpmock.JSONResponse(searchResults()),
httpmock.JSONResponse(searchResults(4)),
)
},
wantStdout: "installed\tvilmibm/gh-screensaver\tterminal animations\n\tcli/gh-cool\tit's just cool ok\n\tsamcoe/gh-triage\thelps with triage\ninstalled\tgithub/gh-gei\tsomething something enterprise\n",
@ -145,9 +144,7 @@ func TestNewCmdExtension(t *testing.T) {
"per_page": []string{"30"},
"q": []string{"screen topic:gh-extension"},
}
results := searchResults()
results.Total = 1
results.Items = []search.Repository{results.Items[0]}
results := searchResults(1)
reg.Register(
httpmock.QueryMatcher("GET", "search/repositories", values),
httpmock.JSONResponse(results),
@ -175,9 +172,7 @@ func TestNewCmdExtension(t *testing.T) {
"per_page": []string{"1"},
"q": []string{"topic:gh-extension"},
}
results := searchResults()
results.Total = 1
results.Items = []search.Repository{results.Items[0]}
results := searchResults(1)
reg.Register(
httpmock.QueryMatcher("GET", "search/repositories", values),
httpmock.JSONResponse(results),
@ -203,9 +198,7 @@ func TestNewCmdExtension(t *testing.T) {
"per_page": []string{"30"},
"q": []string{"license:GPLv3 topic:gh-extension user:jillvalentine"},
}
results := searchResults()
results.Total = 1
results.Items = []search.Repository{results.Items[0]}
results := searchResults(1)
reg.Register(
httpmock.QueryMatcher("GET", "search/repositories", values),
httpmock.JSONResponse(results),
@ -974,7 +967,7 @@ func Test_checkValidExtension(t *testing.T) {
manager: m,
extName: "gh-auth",
},
wantError: "\"auth\" matches the name of a built-in command",
wantError: "\"auth\" matches the name of a built-in command or alias",
},
{
name: "clashes with an installed extension",
@ -998,43 +991,48 @@ func Test_checkValidExtension(t *testing.T) {
}
}
func searchResults() search.RepositoriesResult {
return search.RepositoriesResult{
IncompleteResults: false,
Items: []search.Repository{
{
FullName: "vilmibm/gh-screensaver",
Name: "gh-screensaver",
Description: "terminal animations",
Owner: search.User{
Login: "vilmibm",
func searchResults(numResults int) interface{} {
result := map[string]interface{}{
"incomplete_results": false,
"total_count": 4,
"items": []interface{}{
map[string]interface{}{
"name": "gh-screensaver",
"full_name": "vilmibm/gh-screensaver",
"description": "terminal animations",
"owner": map[string]interface{}{
"login": "vilmibm",
},
},
{
FullName: "cli/gh-cool",
Name: "gh-cool",
Description: "it's just cool ok",
Owner: search.User{
Login: "cli",
map[string]interface{}{
"name": "gh-cool",
"full_name": "cli/gh-cool",
"description": "it's just cool ok",
"owner": map[string]interface{}{
"login": "cli",
},
},
{
FullName: "samcoe/gh-triage",
Name: "gh-triage",
Description: "helps with triage",
Owner: search.User{
Login: "samcoe",
map[string]interface{}{
"name": "gh-triage",
"full_name": "samcoe/gh-triage",
"description": "helps with triage",
"owner": map[string]interface{}{
"login": "samcoe",
},
},
{
FullName: "github/gh-gei",
Name: "gh-gei",
Description: "something something enterprise",
Owner: search.User{
Login: "github",
map[string]interface{}{
"name": "gh-gei",
"full_name": "github/gh-gei",
"description": "something something enterprise",
"owner": map[string]interface{}{
"login": "github",
},
},
},
Total: 4,
}
if len(result["items"].([]interface{})) > numResults {
fewerItems := result["items"].([]interface{})[0:numResults]
result["items"] = fewerItems
}
return result
}

View file

@ -3,12 +3,12 @@ package main
import (
"fmt"
"github.com/cli/go-gh"
"github.com/cli/go-gh/v2/pkg/api"
)
func main() {
fmt.Println("hi world, this is the %s extension!")
client, err := gh.RESTClient(nil)
client, err := api.DefaultRESTClient()
if err != nil {
fmt.Println(err)
return

View file

@ -1,8 +1,16 @@
package extension
import (
"bytes"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"github.com/cli/cli/v2/internal/ghrepo"
"gopkg.in/yaml.v3"
)
const manifestName = "manifest.yml"
@ -12,16 +20,22 @@ type ExtensionKind int
const (
GitKind ExtensionKind = iota
BinaryKind
LocalKind
)
type Extension struct {
path string
path string
kind ExtensionKind
gitClient gitClient
httpClient *http.Client
mu sync.RWMutex
// These fields get resolved dynamically:
url string
isLocal bool
isPinned bool
isPinned *bool
currentVersion string
latestVersion string
kind ExtensionKind
}
func (e *Extension) Name() string {
@ -32,32 +46,157 @@ func (e *Extension) Path() string {
return e.path
}
func (e *Extension) URL() string {
return e.url
}
func (e *Extension) IsLocal() bool {
return e.isLocal
}
func (e *Extension) CurrentVersion() string {
return e.currentVersion
}
func (e *Extension) IsPinned() bool {
return e.isPinned
}
func (e *Extension) UpdateAvailable() bool {
if e.isLocal ||
e.currentVersion == "" ||
e.latestVersion == "" ||
e.currentVersion == e.latestVersion {
return false
}
return true
return e.kind == LocalKind
}
func (e *Extension) IsBinary() bool {
return e.kind == BinaryKind
}
func (e *Extension) URL() string {
e.mu.RLock()
if e.url != "" {
defer e.mu.RUnlock()
return e.url
}
e.mu.RUnlock()
var url string
switch e.kind {
case LocalKind:
case BinaryKind:
if manifest, err := e.loadManifest(); err == nil {
repo := ghrepo.NewWithHost(manifest.Owner, manifest.Name, manifest.Host)
url = ghrepo.GenerateRepoURL(repo, "")
}
case GitKind:
if remoteURL, err := e.gitClient.Config("remote.origin.url"); err == nil {
url = strings.TrimSpace(string(remoteURL))
}
}
e.mu.Lock()
e.url = url
e.mu.Unlock()
return e.url
}
func (e *Extension) CurrentVersion() string {
e.mu.RLock()
if e.currentVersion != "" {
defer e.mu.RUnlock()
return e.currentVersion
}
e.mu.RUnlock()
var currentVersion string
switch e.kind {
case LocalKind:
case BinaryKind:
if manifest, err := e.loadManifest(); err == nil {
currentVersion = manifest.Tag
}
case GitKind:
if sha, err := e.gitClient.CommandOutput([]string{"rev-parse", "HEAD"}); err == nil {
currentVersion = string(bytes.TrimSpace(sha))
}
}
e.mu.Lock()
e.currentVersion = currentVersion
e.mu.Unlock()
return e.currentVersion
}
func (e *Extension) LatestVersion() string {
e.mu.RLock()
if e.latestVersion != "" {
defer e.mu.RUnlock()
return e.latestVersion
}
e.mu.RUnlock()
var latestVersion string
switch e.kind {
case LocalKind:
case BinaryKind:
repo, err := ghrepo.FromFullName(e.URL())
if err != nil {
return ""
}
release, err := fetchLatestRelease(e.httpClient, repo)
if err != nil {
return ""
}
latestVersion = release.Tag
case GitKind:
if lsRemote, err := e.gitClient.CommandOutput([]string{"ls-remote", "origin", "HEAD"}); err == nil {
latestVersion = string(bytes.SplitN(lsRemote, []byte("\t"), 2)[0])
}
}
e.mu.Lock()
e.latestVersion = latestVersion
e.mu.Unlock()
return e.latestVersion
}
func (e *Extension) IsPinned() bool {
e.mu.RLock()
if e.isPinned != nil {
defer e.mu.RUnlock()
return *e.isPinned
}
e.mu.RUnlock()
var isPinned bool
switch e.kind {
case LocalKind:
case BinaryKind:
if manifest, err := e.loadManifest(); err == nil {
isPinned = manifest.IsPinned
}
case GitKind:
pinPath := filepath.Join(e.Path(), fmt.Sprintf(".pin-%s", e.CurrentVersion()))
if _, err := os.Stat(pinPath); err == nil {
isPinned = true
} else {
isPinned = false
}
}
e.mu.Lock()
e.isPinned = &isPinned
e.mu.Unlock()
return *e.isPinned
}
func (e *Extension) UpdateAvailable() bool {
if e.IsLocal() ||
e.CurrentVersion() == "" ||
e.LatestVersion() == "" ||
e.CurrentVersion() == e.LatestVersion() {
return false
}
return true
}
func (e *Extension) loadManifest() (binManifest, error) {
var bm binManifest
dir, _ := filepath.Split(e.Path())
manifestPath := filepath.Join(dir, manifestName)
manifest, err := os.ReadFile(manifestPath)
if err != nil {
return bm, fmt.Errorf("could not open %s for reading: %w", manifestPath, err)
}
err = yaml.Unmarshal(manifest, &bm)
if err != nil {
return bm, fmt.Errorf("could not parse %s: %w", manifestPath, err)
}
return bm, nil
}

View file

@ -8,7 +8,7 @@ import (
func TestUpdateAvailable_IsLocal(t *testing.T) {
e := &Extension{
isLocal: true,
kind: LocalKind,
}
assert.False(t, e.UpdateAvailable())
@ -16,7 +16,7 @@ func TestUpdateAvailable_IsLocal(t *testing.T) {
func TestUpdateAvailable_NoCurrentVersion(t *testing.T) {
e := &Extension{
isLocal: false,
kind: LocalKind,
}
assert.False(t, e.UpdateAvailable())
@ -24,7 +24,7 @@ func TestUpdateAvailable_NoCurrentVersion(t *testing.T) {
func TestUpdateAvailable_NoLatestVersion(t *testing.T) {
e := &Extension{
isLocal: false,
kind: BinaryKind,
currentVersion: "1.0.0",
}
@ -33,7 +33,7 @@ func TestUpdateAvailable_NoLatestVersion(t *testing.T) {
func TestUpdateAvailable_CurrentVersionIsLatestVersion(t *testing.T) {
e := &Extension{
isLocal: false,
kind: BinaryKind,
currentVersion: "1.0.0",
latestVersion: "1.0.0",
}
@ -43,7 +43,7 @@ func TestUpdateAvailable_CurrentVersionIsLatestVersion(t *testing.T) {
func TestUpdateAvailable(t *testing.T) {
e := &Extension{
isLocal: false,
kind: BinaryKind,
currentVersion: "1.0.0",
latestVersion: "1.1.0",
}

View file

@ -46,16 +46,9 @@ func (g *gitExecuter) Fetch(remote string, refspec string) error {
}
func (g *gitExecuter) ForRepo(repoDir string) gitClient {
return &gitExecuter{
client: &git.Client{
GhPath: g.client.GhPath,
RepoDir: repoDir,
GitPath: g.client.GitPath,
Stderr: g.client.Stderr,
Stdin: g.client.Stdin,
Stdout: g.client.Stdout,
},
}
gc := g.client.Copy()
gc.RepoDir = repoDir
return &gitExecuter{client: gc}
}
func (g *gitExecuter) Pull(remote, branch string) error {

View file

@ -1,12 +1,10 @@
package extension
import (
"bytes"
_ "embed"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"os/exec"
@ -83,7 +81,7 @@ func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Wri
forwardArgs := args[1:]
exts, _ := m.list(false)
var ext Extension
var ext *Extension
for _, e := range exts {
if e.Name() == extName {
ext = e
@ -121,39 +119,53 @@ func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Wri
func (m *Manager) List() []extensions.Extension {
exts, _ := m.list(false)
r := make([]extensions.Extension, len(exts))
for i, v := range exts {
val := v
r[i] = &val
for i, ext := range exts {
r[i] = ext
}
return r
}
func (m *Manager) list(includeMetadata bool) ([]Extension, error) {
func (m *Manager) list(includeMetadata bool) ([]*Extension, error) {
dir := m.installDir()
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
var results []Extension
results := make([]*Extension, 0, len(entries))
for _, f := range entries {
if !strings.HasPrefix(f.Name(), "gh-") {
continue
}
var ext Extension
var err error
if f.IsDir() {
ext, err = m.parseExtensionDir(f)
if err != nil {
return nil, err
if _, err := os.Stat(filepath.Join(dir, f.Name(), manifestName)); err == nil {
results = append(results, &Extension{
path: filepath.Join(dir, f.Name(), f.Name()),
kind: BinaryKind,
httpClient: m.client,
})
} else {
results = append(results, &Extension{
path: filepath.Join(dir, f.Name(), f.Name()),
kind: GitKind,
gitClient: m.gitClient.ForRepo(filepath.Join(dir, f.Name())),
})
}
results = append(results, ext)
} else if isSymlink(f.Type()) {
results = append(results, &Extension{
path: filepath.Join(dir, f.Name(), f.Name()),
kind: LocalKind,
})
} else {
ext, err = m.parseExtensionFile(f)
// the contents of a regular file point to a local extension on disk
p, err := readPathFromFile(filepath.Join(dir, f.Name()))
if err != nil {
return nil, err
}
results = append(results, ext)
results = append(results, &Extension{
path: filepath.Join(p, f.Name()),
kind: LocalKind,
})
}
}
@ -164,145 +176,16 @@ func (m *Manager) list(includeMetadata bool) ([]Extension, error) {
return results, nil
}
func (m *Manager) parseExtensionFile(fi fs.DirEntry) (Extension, error) {
ext := Extension{isLocal: true}
id := m.installDir()
exePath := filepath.Join(id, fi.Name(), fi.Name())
if !isSymlink(fi.Type()) {
// if this is a regular file, its contents is the local directory of the extension
p, err := readPathFromFile(filepath.Join(id, fi.Name()))
if err != nil {
return ext, err
}
exePath = filepath.Join(p, fi.Name())
}
ext.path = exePath
return ext, nil
}
func (m *Manager) parseExtensionDir(fi fs.DirEntry) (Extension, error) {
id := m.installDir()
if _, err := os.Stat(filepath.Join(id, fi.Name(), manifestName)); err == nil {
return m.parseBinaryExtensionDir(fi)
}
return m.parseGitExtensionDir(fi)
}
func (m *Manager) parseBinaryExtensionDir(fi fs.DirEntry) (Extension, error) {
id := m.installDir()
exePath := filepath.Join(id, fi.Name(), fi.Name())
ext := Extension{path: exePath, kind: BinaryKind}
manifestPath := filepath.Join(id, fi.Name(), manifestName)
manifest, err := os.ReadFile(manifestPath)
if err != nil {
return ext, fmt.Errorf("could not open %s for reading: %w", manifestPath, err)
}
var bm binManifest
err = yaml.Unmarshal(manifest, &bm)
if err != nil {
return ext, fmt.Errorf("could not parse %s: %w", manifestPath, err)
}
repo := ghrepo.NewWithHost(bm.Owner, bm.Name, bm.Host)
remoteURL := ghrepo.GenerateRepoURL(repo, "")
ext.url = remoteURL
ext.currentVersion = bm.Tag
ext.isPinned = bm.IsPinned
return ext, nil
}
func (m *Manager) parseGitExtensionDir(fi fs.DirEntry) (Extension, error) {
id := m.installDir()
exePath := filepath.Join(id, fi.Name(), fi.Name())
remoteUrl := m.getRemoteUrl(fi.Name())
currentVersion := m.getCurrentVersion(fi.Name())
var isPinned bool
pinPath := filepath.Join(id, fi.Name(), fmt.Sprintf(".pin-%s", currentVersion))
if _, err := os.Stat(pinPath); err == nil {
isPinned = true
}
return Extension{
path: exePath,
url: remoteUrl,
isLocal: false,
currentVersion: currentVersion,
kind: GitKind,
isPinned: isPinned,
}, nil
}
// getCurrentVersion determines the current version for non-local git extensions.
func (m *Manager) getCurrentVersion(extension string) string {
dir := filepath.Join(m.installDir(), extension)
scopedClient := m.gitClient.ForRepo(dir)
localSha, err := scopedClient.CommandOutput([]string{"rev-parse", "HEAD"})
if err != nil {
return ""
}
return string(bytes.TrimSpace(localSha))
}
// getRemoteUrl determines the remote URL for non-local git extensions.
func (m *Manager) getRemoteUrl(extension string) string {
dir := filepath.Join(m.installDir(), extension)
scopedClient := m.gitClient.ForRepo(dir)
url, err := scopedClient.Config("remote.origin.url")
if err != nil {
return ""
}
return strings.TrimSpace(string(url))
}
func (m *Manager) populateLatestVersions(exts []Extension) {
size := len(exts)
type result struct {
index int
version string
}
ch := make(chan result, size)
func (m *Manager) populateLatestVersions(exts []*Extension) {
var wg sync.WaitGroup
wg.Add(size)
for idx, ext := range exts {
go func(i int, e Extension) {
for _, ext := range exts {
wg.Add(1)
go func(e *Extension) {
defer wg.Done()
version, _ := m.getLatestVersion(e)
ch <- result{index: i, version: version}
}(idx, ext)
e.LatestVersion()
}(ext)
}
wg.Wait()
close(ch)
for r := range ch {
ext := &exts[r.index]
ext.latestVersion = r.version
}
}
func (m *Manager) getLatestVersion(ext Extension) (string, error) {
if ext.isLocal {
return "", localExtensionUpgradeError
}
if ext.IsBinary() {
repo, err := ghrepo.FromFullName(ext.url)
if err != nil {
return "", err
}
r, err := fetchLatestRelease(m.client, repo)
if err != nil {
return "", err
}
return r.Tag, nil
} else {
extDir := filepath.Dir(ext.path)
scopedClient := m.gitClient.ForRepo(extDir)
lsRemote, err := scopedClient.CommandOutput([]string{"ls-remote", "origin", "HEAD"})
if err != nil {
return "", err
}
remoteSha := bytes.SplitN(lsRemote, []byte("\t"), 2)[0]
return string(remoteSha), nil
}
}
func (m *Manager) InstallLocal(dir string) error {
@ -521,22 +404,23 @@ func (m *Manager) Upgrade(name string, force bool) error {
if f.Name() != name {
continue
}
var err error
// For single extensions manually retrieve latest version since we forgo
// doing it during list.
f.latestVersion, err = m.getLatestVersion(f)
if err != nil {
return err
if f.IsLocal() {
return localExtensionUpgradeError
}
return m.upgradeExtensions([]Extension{f}, force)
// For single extensions manually retrieve latest version since we forgo doing it during list.
if latestVersion := f.LatestVersion(); latestVersion == "" {
return fmt.Errorf("unable to retrieve latest version for extension %q", name)
}
return m.upgradeExtensions([]*Extension{f}, force)
}
return fmt.Errorf("no extension matched %q", name)
}
func (m *Manager) upgradeExtensions(exts []Extension, force bool) error {
func (m *Manager) upgradeExtensions(exts []*Extension, force bool) error {
var failed bool
for _, f := range exts {
fmt.Fprintf(m.io.Out, "[%s]: ", f.Name())
currentVersion := displayExtensionVersion(f, f.CurrentVersion())
err := m.upgradeExtension(f, force)
if err != nil {
if !errors.Is(err, localExtensionUpgradeError) &&
@ -547,8 +431,7 @@ func (m *Manager) upgradeExtensions(exts []Extension, force bool) error {
fmt.Fprintf(m.io.Out, "%s\n", err)
continue
}
currentVersion := displayExtensionVersion(&f, f.currentVersion)
latestVersion := displayExtensionVersion(&f, f.latestVersion)
latestVersion := displayExtensionVersion(f, f.LatestVersion())
if m.dryRunMode {
fmt.Fprintf(m.io.Out, "would have upgraded from %s to %s\n", currentVersion, latestVersion)
} else {
@ -561,8 +444,8 @@ func (m *Manager) upgradeExtensions(exts []Extension, force bool) error {
return nil
}
func (m *Manager) upgradeExtension(ext Extension, force bool) error {
if ext.isLocal {
func (m *Manager) upgradeExtension(ext *Extension, force bool) error {
if ext.IsLocal() {
return localExtensionUpgradeError
}
if !force && ext.IsPinned() {
@ -592,7 +475,7 @@ func (m *Manager) upgradeExtension(ext Extension, force bool) error {
return err
}
func (m *Manager) upgradeGitExtension(ext Extension, force bool) error {
func (m *Manager) upgradeGitExtension(ext *Extension, force bool) error {
if m.dryRunMode {
return nil
}
@ -611,10 +494,10 @@ func (m *Manager) upgradeGitExtension(ext Extension, force bool) error {
return scopedClient.Pull("", "")
}
func (m *Manager) upgradeBinExtension(ext Extension) error {
repo, err := ghrepo.FromFullName(ext.url)
func (m *Manager) upgradeBinExtension(ext *Extension) error {
repo, err := ghrepo.FromFullName(ext.URL())
if err != nil {
return fmt.Errorf("failed to parse URL %s: %w", ext.url, err)
return fmt.Errorf("failed to parse URL %s: %w", ext.URL(), err)
}
return m.installBin(repo, "")
}

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