Merge branch 'trunk' into feature-macos-pkg-installer
This commit is contained in:
commit
630ab13461
498 changed files with 27825 additions and 7758 deletions
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"image": "mcr.microsoft.com/devcontainers/go:1.19",
|
||||
"image": "mcr.microsoft.com/devcontainers/go:1.22",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/sshd:1": {}
|
||||
},
|
||||
|
|
|
|||
7
.github/CODEOWNERS
vendored
7
.github/CODEOWNERS
vendored
|
|
@ -1,5 +1,10 @@
|
|||
* @cli/code-reviewers
|
||||
|
||||
pkg/cmd/codespace/ @cli/codespaces
|
||||
pkg/liveshare/ @cli/codespaces
|
||||
internal/codespaces/ @cli/codespaces
|
||||
|
||||
# Limit Package Security team ownership to the attestation command package and related integration tests
|
||||
pkg/cmd/attestation/ @cli/package-security
|
||||
test/integration/attestation-cmd @cli/package-security
|
||||
|
||||
pkg/cmd/attestation/verification/embed/tuf-repo.github.com/ @cli/tuf-root-reviewers
|
||||
|
|
|
|||
3
.github/CONTRIBUTING.md
vendored
3
.github/CONTRIBUTING.md
vendored
|
|
@ -16,6 +16,7 @@ Please do:
|
|||
Please avoid:
|
||||
|
||||
* Opening pull requests for issues marked `needs-design`, `needs-investigation`, or `blocked`.
|
||||
* Opening pull requests that haven't been approved for work in an issue
|
||||
* Adding installation instructions specifically for your OS/package manager.
|
||||
* Opening pull requests for any issue marked `core`. These issues require additional context from
|
||||
the core CLI team at GitHub and any external pull requests will not be accepted.
|
||||
|
|
@ -23,7 +24,7 @@ Please avoid:
|
|||
## Building the project
|
||||
|
||||
Prerequisites:
|
||||
- Go 1.21+
|
||||
- Go 1.22+
|
||||
|
||||
Build with:
|
||||
* Unix-like systems: `make`
|
||||
|
|
|
|||
11
.github/workflows/codeql.yml
vendored
11
.github/workflows/codeql.yml
vendored
|
|
@ -21,13 +21,18 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: go
|
||||
queries: security-and-quality
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
uses: github/codeql-action/analyze@v3
|
||||
|
|
|
|||
99
.github/workflows/deployment.yml
vendored
99
.github/workflows/deployment.yml
vendored
|
|
@ -1,4 +1,5 @@
|
|||
name: Deployment
|
||||
run-name: ${{ inputs.tag_name }} / ${{ inputs.environment }}
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name }}
|
||||
|
|
@ -16,9 +17,6 @@ on:
|
|||
environment:
|
||||
default: production
|
||||
type: environment
|
||||
go_version:
|
||||
default: "1.21"
|
||||
type: string
|
||||
platforms:
|
||||
default: "linux,macos,windows"
|
||||
type: string
|
||||
|
|
@ -34,13 +32,13 @@ jobs:
|
|||
if: contains(inputs.platforms, 'linux')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ inputs.go_version }}
|
||||
go-version-file: 'go.mod'
|
||||
- name: Install GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v4
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
with:
|
||||
version: "~1.17.1"
|
||||
install-only: true
|
||||
|
|
@ -52,7 +50,7 @@ jobs:
|
|||
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
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux
|
||||
if-no-files-found: error
|
||||
|
|
@ -68,11 +66,11 @@ jobs:
|
|||
if: contains(inputs.platforms, 'macos')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ inputs.go_version }}
|
||||
go-version-file: 'go.mod'
|
||||
- name: Configure macOS signing
|
||||
if: inputs.environment == 'production'
|
||||
env:
|
||||
|
|
@ -92,7 +90,7 @@ jobs:
|
|||
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
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
with:
|
||||
version: "~1.17.1"
|
||||
install-only: true
|
||||
|
|
@ -123,7 +121,7 @@ jobs:
|
|||
run: |
|
||||
shopt -s failglob
|
||||
script/pkgmacos "$TAG_NAME"
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos
|
||||
if-no-files-found: error
|
||||
|
|
@ -139,35 +137,52 @@ jobs:
|
|||
if: contains(inputs.platforms, 'windows')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v5
|
||||
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 }}
|
||||
go-version-file: 'go.mod'
|
||||
- name: Install GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v4
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
with:
|
||||
version: "~1.17.1"
|
||||
install-only: true
|
||||
- name: Install Azure Code Signing Client
|
||||
shell: pwsh
|
||||
env:
|
||||
ACS_DIR: ${{ runner.temp }}\acs
|
||||
ACS_ZIP: ${{ runner.temp }}\acs.zip
|
||||
CORRELATION_ID: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
|
||||
run: |
|
||||
# Download Azure Code Signing client containing the DLL needed for signtool in script/sign
|
||||
Invoke-WebRequest -Uri https://www.nuget.org/api/v2/package/Azure.CodeSigning.Client/1.0.43 -OutFile $Env:ACS_ZIP -Verbose
|
||||
Expand-Archive $Env:ACS_ZIP -Destination $Env:ACS_DIR -Force -Verbose
|
||||
|
||||
# Generate metadata file for signtool, used in signing box .exe and .msi
|
||||
@{
|
||||
CertificateProfileName = "GitHubInc"
|
||||
CodeSigningAccountName = "GitHubInc"
|
||||
CorrelationId = $Env:CORRELATION_ID
|
||||
Endpoint = "https://wus.codesigning.azure.net/"
|
||||
} | ConvertTo-Json | Out-File -FilePath $Env:METADATA_PATH
|
||||
|
||||
# Azure Code Signing leverages the environment variables for secrets that complement the metadata.json
|
||||
# file generated above (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
|
||||
# For more information, see https://learn.microsoft.com/en-us/dotnet/api/azure.identity.defaultazurecredential?view=azure-dotnet
|
||||
- name: Build release binaries
|
||||
shell: bash
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.SPN_GITHUB_CLI_SIGNING }}
|
||||
AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }}
|
||||
DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll
|
||||
METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
|
||||
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
|
||||
uses: microsoft/setup-msbuild@v2.0.0
|
||||
- name: Build MSI
|
||||
shell: bash
|
||||
env:
|
||||
|
|
@ -198,17 +213,20 @@ jobs:
|
|||
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
|
||||
- name: Sign .msi release binaries
|
||||
if: inputs.environment == 'production'
|
||||
shell: pwsh
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_CLIENT_ID }}
|
||||
AZURE_CLIENT_SECRET: ${{ secrets.SPN_GITHUB_CLI_SIGNING }}
|
||||
AZURE_TENANT_ID: ${{ secrets.SPN_GITHUB_CLI_SIGNING_TENANT_ID }}
|
||||
DLIB_PATH: ${{ runner.temp }}\acs\bin\x64\Azure.CodeSigning.Dlib.dll
|
||||
METADATA_PATH: ${{ runner.temp }}\acs\metadata.json
|
||||
run: |
|
||||
Get-ChildItem -Path .\dist -Filter *.msi | ForEach-Object {
|
||||
.\script\sign $_.FullName
|
||||
.\script\sign.ps1 $_.FullName
|
||||
}
|
||||
env:
|
||||
CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }}
|
||||
CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
|
||||
- uses: actions/upload-artifact@v3
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows
|
||||
if-no-files-found: error
|
||||
|
|
@ -220,14 +238,15 @@ jobs:
|
|||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [linux, macos, windows]
|
||||
environment: ${{ inputs.environment }}
|
||||
if: inputs.release
|
||||
steps:
|
||||
- name: Checkout cli/cli
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Merge built artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Checkout documentation site
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: github/cli.github.com
|
||||
path: site
|
||||
|
|
@ -350,12 +369,12 @@ jobs:
|
|||
git diff --name-status @{upstream}..
|
||||
fi
|
||||
- name: Bump homebrew-core formula
|
||||
uses: mislav/bump-homebrew-formula-action@v2
|
||||
uses: mislav/bump-homebrew-formula-action@v3
|
||||
if: inputs.environment == 'production' && !contains(inputs.tag_name, '-')
|
||||
with:
|
||||
formula-name: gh
|
||||
formula-path: Formula/g/gh.rb
|
||||
tag-name: ${{ inputs.tag_name }}
|
||||
push-to: cli/homebrew-core
|
||||
push-to: williammartin/homebrew-core
|
||||
env:
|
||||
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }}
|
||||
|
|
|
|||
48
.github/workflows/go.yml
vendored
48
.github/workflows/go.yml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
name: Tests
|
||||
name: Unit and Integration Tests
|
||||
on: [push, pull_request]
|
||||
|
||||
permissions:
|
||||
|
|
@ -13,27 +13,45 @@ jobs:
|
|||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Set up Go 1.21
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: 1.21
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore Go modules cache
|
||||
uses: actions/cache@v3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: go-${{ runner.os }}-${{ hashFiles('go.mod') }}
|
||||
restore-keys: |
|
||||
go-${{ runner.os }}-
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Download dependencies
|
||||
run: go mod download
|
||||
|
||||
- name: Run tests
|
||||
run: go test -race ./...
|
||||
- name: Run unit and integration tests
|
||||
run: go test -race -tags=integration ./...
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./cmd/gh
|
||||
|
||||
integration-tests:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Build executable
|
||||
run: make
|
||||
|
||||
- name: Run attestation command integration Tests
|
||||
run: |
|
||||
./test/integration/attestation-cmd/download-and-verify-package-attestation.sh
|
||||
./test/integration/attestation-cmd/verify-sigstore-bundle-versions.sh
|
||||
|
|
|
|||
26
.github/workflows/homebrew-bump.yml
vendored
Normal file
26
.github/workflows/homebrew-bump.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
name: homebrew-bump-debug
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
required: true
|
||||
type: string
|
||||
environment:
|
||||
default: production
|
||||
type: environment
|
||||
jobs:
|
||||
bump:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Bump homebrew-core formula
|
||||
uses: mislav/bump-homebrew-formula-action@v3
|
||||
if: inputs.environment == 'production' && !contains(inputs.tag_name, '-')
|
||||
with:
|
||||
formula-name: gh
|
||||
tag-name: ${{ inputs.tag_name }}
|
||||
env:
|
||||
COMMITTER_TOKEN: ${{ secrets.HOMEBREW_PR_PAT }}
|
||||
16
.github/workflows/lint.yml
vendored
16
.github/workflows/lint.yml
vendored
|
|
@ -19,21 +19,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Set up Go 1.21
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: 1.21
|
||||
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Restore Go modules cache
|
||||
uses: actions/cache@v3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: go-${{ runner.os }}-${{ hashFiles('go.mod') }}
|
||||
restore-keys: |
|
||||
go-${{ runner.os }}-
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Verify dependencies
|
||||
run: |
|
||||
|
|
|
|||
67
.github/workflows/triage.yml
vendored
Normal file
67
.github/workflows/triage.yml
vendored
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
name: Discussion Triage
|
||||
run-name: ${{ github.event_name == 'issues' && github.event.issue.title || github.event.pull_request.title }}
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- labeled
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
env:
|
||||
TARGET_REPO: github/cli
|
||||
jobs:
|
||||
issue:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'issues' && github.event.action == 'labeled' && github.event.label.name == 'discuss'
|
||||
steps:
|
||||
- name: Create issue based on source issue
|
||||
env:
|
||||
BODY: ${{ github.event.issue.body }}
|
||||
CREATED: ${{ github.event.issue.created_at }}
|
||||
GH_TOKEN: ${{ secrets.CLI_DISCUSSION_TRIAGE_TOKEN }}
|
||||
LINK: ${{ github.repository }}#${{ github.event.issue.number }}
|
||||
TITLE: ${{ github.event.issue.title }}
|
||||
TRIGGERED_BY: ${{ github.triggering_actor }}
|
||||
run: |
|
||||
# Markdown quote source body by replacing newlines for newlines and markdown quoting
|
||||
BODY="${BODY//$'\n'/$'\n'> }"
|
||||
|
||||
# Create issue using dynamically constructed body within heredoc
|
||||
cat << EOF | gh issue create --title "Triage issue \"$TITLE\"" --body-file - --repo "$TARGET_REPO" --label triage
|
||||
**Title:** $TITLE
|
||||
**Issue:** $LINK
|
||||
**Created:** $CREATED
|
||||
**Triggered by:** @$TRIGGERED_BY
|
||||
|
||||
---
|
||||
|
||||
> $BODY
|
||||
EOF
|
||||
|
||||
pull_request:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request_target' && github.event.action == 'labeled' && github.event.label.name == 'discuss'
|
||||
steps:
|
||||
- name: Create issue based on source pull request
|
||||
env:
|
||||
BODY: ${{ github.event.pull_request.body }}
|
||||
CREATED: ${{ github.event.pull_request.created_at }}
|
||||
GH_TOKEN: ${{ secrets.CLI_DISCUSSION_TRIAGE_TOKEN }}
|
||||
LINK: ${{ github.repository }}#${{ github.event.pull_request.number }}
|
||||
TITLE: ${{ github.event.pull_request.title }}
|
||||
TRIGGERED_BY: ${{ github.triggering_actor }}
|
||||
run: |
|
||||
# Markdown quote source body by replacing newlines for newlines and markdown quoting
|
||||
BODY="${BODY//$'\n'/$'\n'> }"
|
||||
|
||||
# Create issue using dynamically constructed body within heredoc
|
||||
cat << EOF | gh issue create --title "Triage PR \"$TITLE\"" --body-file - --repo "$TARGET_REPO" --label triage
|
||||
**Title:** $TITLE
|
||||
**Pull request:** $LINK
|
||||
**Created:** $CREATED
|
||||
**Triggered by:** @$TRIGGERED_BY
|
||||
|
||||
---
|
||||
|
||||
> $BODY
|
||||
EOF
|
||||
|
|
@ -41,7 +41,7 @@ builds:
|
|||
hooks:
|
||||
post:
|
||||
- cmd: >-
|
||||
{{ if eq .Runtime.Goos "windows" }}.\script\sign{{ else }}./script/sign{{ end }} '{{ .Path }}'
|
||||
{{ if eq .Runtime.Goos "windows" }}pwsh .\script\sign.ps1{{ else }}./script/sign{{ end }} '{{ .Path }}'
|
||||
output: true
|
||||
binary: bin/gh
|
||||
main: ./cmd/gh
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -4,7 +4,7 @@
|
|||
|
||||

|
||||
|
||||
GitHub CLI is available for repositories hosted on GitHub.com and GitHub Enterprise Server 2.20+, and to install on macOS, Windows, and Linux.
|
||||
GitHub CLI is supported for users on GitHub.com and GitHub Enterprise Server 2.20+ with support for macOS, Windows, and Linux.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ If you are a hubber and are interested in shipping new commands for the CLI, che
|
|||
|
||||
### macOS
|
||||
|
||||
`gh` is available via [Homebrew][], [MacPorts][], [Conda][], [Spack][], and as a downloadable binary from the [releases page][].
|
||||
`gh` is available via [Homebrew][], [MacPorts][], [Conda][], [Spack][], [Webi][], and as a downloadable binary from the [releases page][].
|
||||
|
||||
#### Homebrew
|
||||
|
||||
|
|
@ -49,19 +49,27 @@ Additional Conda installation options available on the [gh-feedstock page](https
|
|||
| ------------------ | ---------------------------------------- |
|
||||
| `spack install gh` | `spack uninstall gh && spack install gh` |
|
||||
|
||||
#### Webi
|
||||
|
||||
| Install: | Upgrade: |
|
||||
| ----------------------------------- | ---------------- |
|
||||
| `curl -sS https://webi.sh/gh \| sh` | `webi gh@stable` |
|
||||
|
||||
For more information about the Webi installer see [its homepage](https://webinstall.dev/).
|
||||
|
||||
### Linux & BSD
|
||||
|
||||
`gh` is available via:
|
||||
- [our Debian and RPM repositories](./docs/install_linux.md);
|
||||
- community-maintained repositories in various Linux distros;
|
||||
- OS-agnostic package managers such as [Homebrew](#homebrew), [Conda](#conda), and [Spack](#spack); and
|
||||
- OS-agnostic package managers such as [Homebrew](#homebrew), [Conda](#conda), [Spack](#spack), [Webi](#webi); and
|
||||
- our [releases page][] as precompiled binaries.
|
||||
|
||||
For more information, see [Linux & BSD installation](./docs/install_linux.md).
|
||||
|
||||
### Windows
|
||||
|
||||
`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#conda), and as downloadable MSI.
|
||||
`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#conda), [Webi](#webi), and as downloadable MSI.
|
||||
|
||||
#### WinGet
|
||||
|
||||
|
|
@ -125,6 +133,7 @@ tool. Check out our [more detailed explanation][gh-vs-hub] to learn more.
|
|||
[Chocolatey]: https://chocolatey.org
|
||||
[Conda]: https://docs.conda.io/en/latest/
|
||||
[Spack]: https://spack.io
|
||||
[Webi]: https://webinstall.dev
|
||||
[releases page]: https://github.com/cli/cli/releases/latest
|
||||
[hub]: https://github.com/github/hub
|
||||
[contributing]: ./.github/CONTRIBUTING.md
|
||||
|
|
|
|||
|
|
@ -234,10 +234,9 @@ func TestHTTPHeaders(t *testing.T) {
|
|||
|
||||
ios, _, _, stderr := iostreams.Test()
|
||||
httpClient, err := NewHTTPClient(HTTPClientOptions{
|
||||
AppVersion: "v1.2.3",
|
||||
Config: tinyConfig{ts.URL[7:] + ":oauth_token": "MYTOKEN"},
|
||||
Log: ios.ErrOut,
|
||||
SkipAcceptHeaders: false,
|
||||
AppVersion: "v1.2.3",
|
||||
Config: tinyConfig{ts.URL[7:] + ":oauth_token": "MYTOKEN"},
|
||||
Log: ios.ErrOut,
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
client := NewClientFromHTTP(httpClient)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} {
|
|||
items := make([]map[string]interface{}, 0, len(issue.ProjectItems.Nodes))
|
||||
for _, n := range issue.ProjectItems.Nodes {
|
||||
items = append(items, map[string]interface{}{
|
||||
"title": n.Project.Title,
|
||||
"status": n.Status,
|
||||
"title": n.Project.Title,
|
||||
})
|
||||
}
|
||||
data[f] = items
|
||||
|
|
@ -108,7 +109,8 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} {
|
|||
items := make([]map[string]interface{}, 0, len(pr.ProjectItems.Nodes))
|
||||
for _, n := range pr.ProjectItems.Nodes {
|
||||
items = append(items, map[string]interface{}{
|
||||
"title": n.Project.Title,
|
||||
"status": n.Status,
|
||||
"title": n.Project.Title,
|
||||
})
|
||||
}
|
||||
data[f] = items
|
||||
|
|
|
|||
|
|
@ -85,6 +85,10 @@ func TestIssue_ExportData(t *testing.T) {
|
|||
"project": {
|
||||
"id": "PVT_id",
|
||||
"title": "Some Project"
|
||||
},
|
||||
"status": {
|
||||
"name": "Todo",
|
||||
"optionId": "abc123"
|
||||
}
|
||||
}
|
||||
] } }
|
||||
|
|
@ -93,6 +97,10 @@ func TestIssue_ExportData(t *testing.T) {
|
|||
{
|
||||
"projectItems": [
|
||||
{
|
||||
"status": {
|
||||
"optionId": "abc123",
|
||||
"name": "Todo"
|
||||
},
|
||||
"title": "Some Project"
|
||||
}
|
||||
]
|
||||
|
|
@ -205,6 +213,38 @@ func TestPullRequest_ExportData(t *testing.T) {
|
|||
}
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "project items",
|
||||
fields: []string{"projectItems"},
|
||||
inputJSON: heredoc.Doc(`
|
||||
{ "projectItems": { "nodes": [
|
||||
{
|
||||
"id": "PVTPR_id",
|
||||
"project": {
|
||||
"id": "PVT_id",
|
||||
"title": "Some Project"
|
||||
},
|
||||
"status": {
|
||||
"name": "Todo",
|
||||
"optionId": "abc123"
|
||||
}
|
||||
}
|
||||
] } }
|
||||
`),
|
||||
outputJSON: heredoc.Doc(`
|
||||
{
|
||||
"projectItems": [
|
||||
{
|
||||
"status": {
|
||||
"optionId": "abc123",
|
||||
"name": "Todo"
|
||||
},
|
||||
"title": "Some Project"
|
||||
}
|
||||
]
|
||||
}
|
||||
`),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -13,18 +13,17 @@ import (
|
|||
)
|
||||
|
||||
type tokenGetter interface {
|
||||
Token(string) (string, string)
|
||||
ActiveToken(string) (string, string)
|
||||
}
|
||||
|
||||
type HTTPClientOptions struct {
|
||||
AppVersion string
|
||||
CacheTTL time.Duration
|
||||
Config tokenGetter
|
||||
EnableCache bool
|
||||
Log io.Writer
|
||||
LogColorize bool
|
||||
LogVerboseHTTP bool
|
||||
SkipAcceptHeaders bool
|
||||
AppVersion string
|
||||
CacheTTL time.Duration
|
||||
Config tokenGetter
|
||||
EnableCache bool
|
||||
Log io.Writer
|
||||
LogColorize bool
|
||||
LogVerboseHTTP bool
|
||||
}
|
||||
|
||||
func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
|
||||
|
|
@ -50,9 +49,6 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
|
|||
headers := map[string]string{
|
||||
userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion),
|
||||
}
|
||||
if opts.SkipAcceptHeaders {
|
||||
headers[accept] = ""
|
||||
}
|
||||
clientOpts.Headers = headers
|
||||
|
||||
if opts.EnableCache {
|
||||
|
|
@ -103,7 +99,7 @@ func AddAuthTokenHeader(rt http.RoundTripper, cfg tokenGetter) http.RoundTripper
|
|||
// If the host has changed during a redirect do not add the authentication token header.
|
||||
if !redirectHostnameChange {
|
||||
hostname := ghinstance.NormalizeHostname(getHost(req))
|
||||
if token, _ := cfg.Token(hostname); token != "" {
|
||||
if token, _ := cfg.ActiveToken(hostname); token != "" {
|
||||
req.Header.Set(authorization, fmt.Sprintf("token %s", token))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
type args struct {
|
||||
config tokenGetter
|
||||
appVersion string
|
||||
setAccept bool
|
||||
logVerboseHTTP bool
|
||||
}
|
||||
tests := []struct {
|
||||
|
|
@ -31,11 +30,10 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
wantStderr string
|
||||
}{
|
||||
{
|
||||
name: "github.com with Accept header",
|
||||
name: "github.com",
|
||||
args: args{
|
||||
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
|
||||
appVersion: "v1.2.3",
|
||||
setAccept: true,
|
||||
logVerboseHTTP: false,
|
||||
},
|
||||
host: "github.com",
|
||||
|
|
@ -47,18 +45,16 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "github.com no Accept header",
|
||||
name: "GHES",
|
||||
args: args{
|
||||
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
|
||||
appVersion: "v1.2.3",
|
||||
setAccept: false,
|
||||
logVerboseHTTP: false,
|
||||
config: tinyConfig{"example.com:oauth_token": "GHETOKEN"},
|
||||
appVersion: "v1.2.3",
|
||||
},
|
||||
host: "github.com",
|
||||
host: "example.com",
|
||||
wantHeader: map[string]string{
|
||||
"authorization": "token MYTOKEN",
|
||||
"authorization": "token GHETOKEN",
|
||||
"user-agent": "GitHub CLI v1.2.3",
|
||||
"accept": "",
|
||||
"accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview",
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
|
|
@ -67,7 +63,6 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
args: args{
|
||||
config: tinyConfig{"example.com:oauth_token": "MYTOKEN"},
|
||||
appVersion: "v1.2.3",
|
||||
setAccept: true,
|
||||
logVerboseHTTP: false,
|
||||
},
|
||||
host: "github.com",
|
||||
|
|
@ -78,12 +73,26 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "GHES no authentication token",
|
||||
args: args{
|
||||
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
|
||||
appVersion: "v1.2.3",
|
||||
logVerboseHTTP: false,
|
||||
},
|
||||
host: "example.com",
|
||||
wantHeader: map[string]string{
|
||||
"authorization": "",
|
||||
"user-agent": "GitHub CLI v1.2.3",
|
||||
"accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview",
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "github.com in verbose mode",
|
||||
args: args{
|
||||
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
|
||||
appVersion: "v1.2.3",
|
||||
setAccept: true,
|
||||
logVerboseHTTP: true,
|
||||
},
|
||||
host: "github.com",
|
||||
|
|
@ -109,21 +118,6 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
* Request took <duration>
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "GHES Accept header",
|
||||
args: args{
|
||||
config: tinyConfig{"example.com:oauth_token": "GHETOKEN"},
|
||||
appVersion: "v1.2.3",
|
||||
setAccept: true,
|
||||
},
|
||||
host: "example.com",
|
||||
wantHeader: map[string]string{
|
||||
"authorization": "token GHETOKEN",
|
||||
"user-agent": "GitHub CLI v1.2.3",
|
||||
"accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview",
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
}
|
||||
|
||||
var gotReq *http.Request
|
||||
|
|
@ -137,11 +131,10 @@ func TestNewHTTPClient(t *testing.T) {
|
|||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, stderr := iostreams.Test()
|
||||
client, err := NewHTTPClient(HTTPClientOptions{
|
||||
AppVersion: tt.args.appVersion,
|
||||
Config: tt.args.config,
|
||||
Log: ios.ErrOut,
|
||||
SkipAcceptHeaders: !tt.args.setAccept,
|
||||
LogVerboseHTTP: tt.args.logVerboseHTTP,
|
||||
AppVersion: tt.args.appVersion,
|
||||
Config: tt.args.config,
|
||||
Log: ios.ErrOut,
|
||||
LogVerboseHTTP: tt.args.logVerboseHTTP,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -229,7 +222,7 @@ func TestHTTPClientSanitizeJSONControlCharactersC0(t *testing.T) {
|
|||
err = json.Unmarshal(body, &issue)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "^[[31mRed Title^[[0m", issue.Title)
|
||||
assert.Equal(t, "1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\t A\r\n B\v C^L D\r\n E^N F^O", issue.Body)
|
||||
assert.Equal(t, "1^A 2^B 3^C 4^D 5^E 6^F 7^G 8\b 9\t A\r\n B\v C\f D\r\n E^N F^O", issue.Body)
|
||||
assert.Equal(t, "10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y 1A^Z 1B^[ 1C^\\ 1D^] 1E^^ 1F^_", issue.Author.Name)
|
||||
assert.Equal(t, "monalisa \\u00^[", issue.Author.Login)
|
||||
assert.Equal(t, "Escaped ^[ \\^[ \\^[ \\\\^[", issue.ActiveLockReason)
|
||||
|
|
@ -272,7 +265,7 @@ func TestHTTPClientSanitizeControlCharactersC1(t *testing.T) {
|
|||
|
||||
type tinyConfig map[string]string
|
||||
|
||||
func (c tinyConfig) Token(host string) (string, string) {
|
||||
func (c tinyConfig) ActiveToken(host string) (string, string) {
|
||||
return c[fmt.Sprintf("%s:%s", host, "oauth_token")], "oauth_token"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -102,10 +102,18 @@ type ProjectInfo struct {
|
|||
|
||||
type ProjectV2Item struct {
|
||||
ID string `json:"id"`
|
||||
Project struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
Project ProjectV2ItemProject
|
||||
Status ProjectV2ItemStatus
|
||||
}
|
||||
|
||||
type ProjectV2ItemProject struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type ProjectV2ItemStatus struct {
|
||||
OptionID string `json:"optionId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (p ProjectCards) ProjectNames() []string {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/shurcooL/githubv4"
|
||||
)
|
||||
|
|
@ -47,6 +48,31 @@ type OrgTeam struct {
|
|||
Slug string
|
||||
}
|
||||
|
||||
// OrganizationTeam fetch the team in an organization with the given slug
|
||||
func OrganizationTeam(client *Client, hostname string, org string, teamSlug string) (*OrgTeam, error) {
|
||||
type responseData struct {
|
||||
Organization struct {
|
||||
Team OrgTeam `graphql:"team(slug: $teamSlug)"`
|
||||
} `graphql:"organization(login: $owner)"`
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"owner": githubv4.String(org),
|
||||
"teamSlug": githubv4.String(teamSlug),
|
||||
}
|
||||
|
||||
var query responseData
|
||||
err := client.Query(hostname, "OrganizationTeam", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if query.Organization.Team.ID == "" {
|
||||
return nil, fmt.Errorf("could not resolve to a Team with the slug of '%s'", teamSlug)
|
||||
}
|
||||
|
||||
return &query.Organization.Team, nil
|
||||
}
|
||||
|
||||
// OrganizationTeams fetches all the teams in an organization
|
||||
func OrganizationTeams(client *Client, repo ghrepo.Interface) ([]OrgTeam, error) {
|
||||
type responseData struct {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ type ProjectV2 struct {
|
|||
Number int `json:"number"`
|
||||
ResourcePath string `json:"resourcePath"`
|
||||
Closed bool `json:"closed"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
// UpdateProjectV2Items uses the addProjectV2ItemById and the deleteProjectV2Item mutations
|
||||
|
|
@ -61,11 +62,27 @@ func UpdateProjectV2Items(client *Client, repo ghrepo.Interface, addProjectItems
|
|||
|
||||
// ProjectsV2ItemsForIssue fetches all ProjectItems for an issue.
|
||||
func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue) error {
|
||||
type projectV2ItemStatus struct {
|
||||
StatusFragment struct {
|
||||
OptionID string `json:"optionId"`
|
||||
Name string `json:"name"`
|
||||
} `graphql:"... on ProjectV2ItemFieldSingleSelectValue"`
|
||||
}
|
||||
|
||||
type projectV2Item struct {
|
||||
ID string `json:"id"`
|
||||
Project struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
Status projectV2ItemStatus `graphql:"status:fieldValueByName(name: \"Status\")"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Repository struct {
|
||||
Issue struct {
|
||||
ProjectItems struct {
|
||||
Nodes []*ProjectV2Item
|
||||
Nodes []*projectV2Item
|
||||
PageInfo struct {
|
||||
HasNextPage bool
|
||||
EndCursor string
|
||||
|
|
@ -87,7 +104,20 @@ func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items.Nodes = append(items.Nodes, query.Repository.Issue.ProjectItems.Nodes...)
|
||||
for _, projectItemNode := range query.Repository.Issue.ProjectItems.Nodes {
|
||||
items.Nodes = append(items.Nodes, &ProjectV2Item{
|
||||
ID: projectItemNode.ID,
|
||||
Project: ProjectV2ItemProject{
|
||||
ID: projectItemNode.Project.ID,
|
||||
Title: projectItemNode.Project.Title,
|
||||
},
|
||||
Status: ProjectV2ItemStatus{
|
||||
OptionID: projectItemNode.Status.StatusFragment.OptionID,
|
||||
Name: projectItemNode.Status.StatusFragment.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if !query.Repository.Issue.ProjectItems.PageInfo.HasNextPage {
|
||||
break
|
||||
}
|
||||
|
|
@ -99,11 +129,27 @@ func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue
|
|||
|
||||
// ProjectsV2ItemsForPullRequest fetches all ProjectItems for a pull request.
|
||||
func ProjectsV2ItemsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
|
||||
type projectV2ItemStatus struct {
|
||||
StatusFragment struct {
|
||||
OptionID string `json:"optionId"`
|
||||
Name string `json:"name"`
|
||||
} `graphql:"... on ProjectV2ItemFieldSingleSelectValue"`
|
||||
}
|
||||
|
||||
type projectV2Item struct {
|
||||
ID string `json:"id"`
|
||||
Project struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
Status projectV2ItemStatus `graphql:"status:fieldValueByName(name: \"Status\")"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Repository struct {
|
||||
PullRequest struct {
|
||||
ProjectItems struct {
|
||||
Nodes []*ProjectV2Item
|
||||
Nodes []*projectV2Item
|
||||
PageInfo struct {
|
||||
HasNextPage bool
|
||||
EndCursor string
|
||||
|
|
@ -125,7 +171,21 @@ func ProjectsV2ItemsForPullRequest(client *Client, repo ghrepo.Interface, pr *Pu
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items.Nodes = append(items.Nodes, query.Repository.PullRequest.ProjectItems.Nodes...)
|
||||
|
||||
for _, projectItemNode := range query.Repository.PullRequest.ProjectItems.Nodes {
|
||||
items.Nodes = append(items.Nodes, &ProjectV2Item{
|
||||
ID: projectItemNode.ID,
|
||||
Project: ProjectV2ItemProject{
|
||||
ID: projectItemNode.Project.ID,
|
||||
Title: projectItemNode.Project.Title,
|
||||
},
|
||||
Status: ProjectV2ItemStatus{
|
||||
OptionID: projectItemNode.Status.StatusFragment.OptionID,
|
||||
Name: projectItemNode.Status.StatusFragment.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if !query.Repository.PullRequest.ProjectItems.PageInfo.HasNextPage {
|
||||
break
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateProjectV2Items(t *testing.T) {
|
||||
|
|
@ -185,6 +186,61 @@ func TestProjectsV2ItemsForPullRequest(t *testing.T) {
|
|||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "retrieves project items that have status columns",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestProjectItems\b`),
|
||||
httpmock.GraphQLQuery(`{
|
||||
"data": {
|
||||
"repository": {
|
||||
"pullRequest": {
|
||||
"projectItems": {
|
||||
"nodes": [
|
||||
{
|
||||
"id": "PVTI_lADOB-vozM4AVk16zgK6U50",
|
||||
"project": {
|
||||
"id": "PVT_kwDOB-vozM4AVk16",
|
||||
"title": "Test Project"
|
||||
},
|
||||
"status": {
|
||||
"optionId": "47fc9ee4",
|
||||
"name": "In Progress"
|
||||
}
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"hasNextPage": false,
|
||||
"endCursor": "MQ"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
func(query string, inputs map[string]interface{}) {
|
||||
require.Equal(t, float64(1), inputs["number"])
|
||||
require.Equal(t, "OWNER", inputs["owner"])
|
||||
require.Equal(t, "REPO", inputs["name"])
|
||||
}),
|
||||
)
|
||||
},
|
||||
expectItems: ProjectItems{
|
||||
Nodes: []*ProjectV2Item{
|
||||
{
|
||||
ID: "PVTI_lADOB-vozM4AVk16zgK6U50",
|
||||
Project: ProjectV2ItemProject{
|
||||
ID: "PVT_kwDOB-vozM4AVk16",
|
||||
Title: "Test Project",
|
||||
},
|
||||
Status: ProjectV2ItemStatus{
|
||||
OptionID: "47fc9ee4",
|
||||
Name: "In Progress",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -199,11 +255,11 @@ func TestProjectsV2ItemsForPullRequest(t *testing.T) {
|
|||
pr := &PullRequest{Number: 1}
|
||||
err := ProjectsV2ItemsForPullRequest(client, repo, pr)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.expectItems, pr.ProjectItems)
|
||||
require.Equal(t, tt.expectItems, pr.ProjectItems)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,9 @@ type Repository struct {
|
|||
Projects struct {
|
||||
Nodes []RepoProject
|
||||
}
|
||||
ProjectsV2 struct {
|
||||
Nodes []ProjectV2
|
||||
}
|
||||
|
||||
// pseudo-field that keeps track of host name of this repo
|
||||
hostname string
|
||||
|
|
@ -548,7 +551,7 @@ func ForkRepo(client *Client, repo ghrepo.Interface, org, newName string, defaul
|
|||
// The GitHub API will happily return a HTTP 200 when attempting to fork own repo even though no forking
|
||||
// actually took place. Ensure that we raise an error instead.
|
||||
if ghrepo.IsSame(repo, newRepo) {
|
||||
return newRepo, fmt.Errorf("%s cannot be forked", ghrepo.FullName(repo))
|
||||
return newRepo, fmt.Errorf("%s cannot be forked. A single user account cannot own both a parent and fork.", ghrepo.FullName(repo))
|
||||
}
|
||||
|
||||
return newRepo, nil
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
|
@ -9,6 +10,7 @@ import (
|
|||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGitHubRepo_notFound(t *testing.T) {
|
||||
|
|
@ -537,3 +539,30 @@ func TestRepoExists(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkRepoReturnsErrorWhenForkIsNotPossible(t *testing.T) {
|
||||
// Given our API returns 202 with a Fork that is the same as
|
||||
// the repo we provided
|
||||
repoName := "test-repo"
|
||||
ownerLogin := "test-owner"
|
||||
stubbedForkResponse := repositoryV3{
|
||||
Name: repoName,
|
||||
Owner: struct{ Login string }{
|
||||
Login: ownerLogin,
|
||||
},
|
||||
}
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
reg.Register(
|
||||
httpmock.REST("POST", fmt.Sprintf("repos/%s/%s/forks", ownerLogin, repoName)),
|
||||
httpmock.StatusJSONResponse(202, stubbedForkResponse),
|
||||
)
|
||||
|
||||
client := newTestClient(reg)
|
||||
|
||||
// When we fork the repo
|
||||
_, err := ForkRepo(client, ghrepo.New(ownerLogin, repoName), ownerLogin, "", false)
|
||||
|
||||
// Then it provides a useful error message
|
||||
require.Equal(t, fmt.Errorf("%s/%s cannot be forked. A single user account cannot own both a parent and fork.", ownerLogin, repoName), err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -318,7 +318,7 @@ func IssueGraphQL(fields []string) string {
|
|||
case "projectCards":
|
||||
q = append(q, `projectCards(first:100){nodes{project{name}column{name}},totalCount}`)
|
||||
case "projectItems":
|
||||
q = append(q, `projectItems(first:100){nodes{id, project{id,title}},totalCount}`)
|
||||
q = append(q, `projectItems(first:100){nodes{id, project{id,title}, status:fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue{optionId,name}}},totalCount}`)
|
||||
case "milestone":
|
||||
q = append(q, `milestone{number,title,description,dueOn}`)
|
||||
case "reactionGroups":
|
||||
|
|
@ -442,6 +442,7 @@ var RepositoryFields = []string{
|
|||
"assignableUsers",
|
||||
"mentionableUsers",
|
||||
"projects",
|
||||
"projectsV2",
|
||||
|
||||
// "branchProtectionRules", // too complex to expose
|
||||
// "collaborators", // does it make sense to expose without affiliation filter?
|
||||
|
|
@ -487,6 +488,8 @@ func RepositoryGraphQL(fields []string) string {
|
|||
q = append(q, "mentionableUsers(first:100){nodes{id,login,name}}")
|
||||
case "projects":
|
||||
q = append(q, "projects(first:100,states:OPEN){nodes{id,name,number,body,resourcePath}}")
|
||||
case "projectsV2":
|
||||
q = append(q, "projectsV2(first:100,query:\"is:open\"){nodes{id,number,title,resourcePath,closed,url}}")
|
||||
case "watchers":
|
||||
q = append(q, "watchers{totalCount}")
|
||||
case "issues":
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ func TestPullRequestGraphQL(t *testing.T) {
|
|||
fields: []string{"isPinned", "stateReason", "number"},
|
||||
want: "number",
|
||||
},
|
||||
{
|
||||
name: "projectItems",
|
||||
fields: []string{"projectItems"},
|
||||
want: `projectItems(first:100){nodes{id, project{id,title}, status:fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue{optionId,name}}},totalCount}`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -69,6 +74,11 @@ func TestIssueGraphQL(t *testing.T) {
|
|||
fields: []string{"files"},
|
||||
want: "files(first: 100) {nodes {additions,deletions,path}}",
|
||||
},
|
||||
{
|
||||
name: "projectItems",
|
||||
fields: []string{"projectItems"},
|
||||
want: `projectItems(first:100){nodes{id, project{id,title}, status:fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue{optionId,name}}},totalCount}`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/docs"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/cmd/root"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
|
|
@ -48,7 +49,7 @@ func run(args []string) error {
|
|||
rootCmd, _ := root.NewCmdRoot(&cmdutil.Factory{
|
||||
IOStreams: ios,
|
||||
Browser: &browser{},
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return config.NewFromString(""), nil
|
||||
},
|
||||
ExtensionManager: &em{},
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ func Test_run(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("error reading `gh-issue-create.1`: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(manPage), `\fB\fCgh issue create`) {
|
||||
if !strings.Contains(string(manPage), `\fBgh issue create`) {
|
||||
t.Fatal("man page corrupted")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/build"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/config/migration"
|
||||
"github.com/cli/cli/v2/internal/update"
|
||||
"github.com/cli/cli/v2/pkg/cmd/factory"
|
||||
"github.com/cli/cli/v2/pkg/cmd/root"
|
||||
|
|
@ -56,6 +57,14 @@ func mainRun() exitCode {
|
|||
|
||||
ctx := context.Background()
|
||||
|
||||
if cfg, err := cmdFactory.Config(); err == nil {
|
||||
var m migration.MultiAccount
|
||||
if err := cfg.Migrate(m); err != nil {
|
||||
fmt.Fprintln(stderr, err)
|
||||
return exitError
|
||||
}
|
||||
}
|
||||
|
||||
updateCtx, updateCancel := context.WithCancel(ctx)
|
||||
defer updateCancel()
|
||||
updateMessageChan := make(chan *update.ReleaseInfo)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ package context
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
|
|
@ -116,9 +117,11 @@ func (r *ResolvedRemotes) HeadRepos() ([]*api.Repository, error) {
|
|||
}
|
||||
|
||||
var results []*api.Repository
|
||||
var ids []string // Check if repo duplicates
|
||||
for _, repo := range r.network.Repositories {
|
||||
if repo != nil && repo.ViewerCanPush() {
|
||||
if repo != nil && repo.ViewerCanPush() && !slices.Contains(ids, repo.ID) {
|
||||
results = append(results, repo)
|
||||
ids = append(ids, repo.ID)
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ func (r Remotes) FindByRepo(owner, name string) (*Remote, error) {
|
|||
return rem, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no matching remote found")
|
||||
return nil, fmt.Errorf("no matching remote found; looking for %s/%s", owner, name)
|
||||
}
|
||||
|
||||
// Filter remotes by given hostnames, maintains original order
|
||||
|
|
|
|||
|
|
@ -28,6 +28,71 @@ func Test_Remotes_FindByName(t *testing.T) {
|
|||
assert.Error(t, err, "no GitHub remotes found")
|
||||
}
|
||||
|
||||
func Test_Remotes_FindByRepo(t *testing.T) {
|
||||
list := Remotes{
|
||||
&Remote{Remote: &git.Remote{Name: "remote-0"}, Repo: ghrepo.New("owner", "repo")},
|
||||
&Remote{Remote: &git.Remote{Name: "remote-1"}, Repo: ghrepo.New("another-owner", "another-repo")},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
owner string
|
||||
repo string
|
||||
wantsRemote *Remote
|
||||
wantsError string
|
||||
}{
|
||||
{
|
||||
name: "exact match (owner/repo)",
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
wantsRemote: list[0],
|
||||
},
|
||||
{
|
||||
name: "exact match (another-owner/another-repo)",
|
||||
owner: "another-owner",
|
||||
repo: "another-repo",
|
||||
wantsRemote: list[1],
|
||||
},
|
||||
{
|
||||
name: "case-insensitive match",
|
||||
owner: "OWNER",
|
||||
repo: "REPO",
|
||||
wantsRemote: list[0],
|
||||
},
|
||||
{
|
||||
name: "non-match (owner)",
|
||||
owner: "unknown-owner",
|
||||
repo: "repo",
|
||||
wantsError: "no matching remote found; looking for unknown-owner/repo",
|
||||
},
|
||||
{
|
||||
name: "non-match (repo)",
|
||||
owner: "owner",
|
||||
repo: "unknown-repo",
|
||||
wantsError: "no matching remote found; looking for owner/unknown-repo",
|
||||
},
|
||||
{
|
||||
name: "non-match (owner, repo)",
|
||||
owner: "unknown-owner",
|
||||
repo: "unknown-repo",
|
||||
wantsError: "no matching remote found; looking for unknown-owner/unknown-repo",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, err := list.FindByRepo(tt.owner, tt.repo)
|
||||
if tt.wantsError != "" {
|
||||
assert.Error(t, err, tt.wantsError)
|
||||
assert.Nil(t, r)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, r, tt.wantsRemote)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type identityTranslator struct{}
|
||||
|
||||
func (it identityTranslator) Translate(u *url.URL) *url.URL {
|
||||
|
|
|
|||
36
docs/codespaces.md
Normal file
36
docs/codespaces.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Guide to working with Codespaces using the CLI
|
||||
|
||||
For more information on Codespaces, see [Codespaces section in GitHub Docs](https://docs.github.com/en/codespaces).
|
||||
|
||||
## Access to other repositories
|
||||
|
||||
The codespace creation process will prompt you to review and authorize additional permissions defined in
|
||||
`devcontainer.json` at creation time:
|
||||
|
||||
```json
|
||||
{
|
||||
"customizations": {
|
||||
"codespaces": {
|
||||
"repositories": {
|
||||
"my_org/my_repo": {
|
||||
"permissions": {
|
||||
"issues": "write"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
However, any changes to `codespaces` customizations will not be re-evaluated for an existing
|
||||
codespace. This requires you to create a new codespace in order to authorize the new
|
||||
permissions using `gh codespace create`.
|
||||
|
||||
For more information, see ["Repository access"](https://docs.github.com/en/codespaces/managing-your-codespaces/managing-repository-access-for-your-codespaces).
|
||||
|
||||
If additional access is needed for an existing codespace or access to a repository outside of
|
||||
your user or organization account, the use of a fine-grained personal access token as an
|
||||
environment variable or Codespaces secret might be considered.
|
||||
|
||||
For more information, see ["Authenticating to repositories"](https://docs.github.com/en/codespaces/troubleshooting/troubleshooting-authentication-to-a-repository).
|
||||
|
|
@ -14,10 +14,11 @@ our release schedule.
|
|||
Install:
|
||||
|
||||
```bash
|
||||
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
||||
(type -p wget >/dev/null || (sudo apt update && sudo apt-get install wget -y)) \
|
||||
&& sudo mkdir -p -m 755 /etc/apt/keyrings \
|
||||
&& wget -qO- https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
|
||||
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
|
||||
&& sudo apt update \
|
||||
&& sudo apt install gh -y
|
||||
```
|
||||
|
|
@ -105,7 +106,7 @@ There are [so many issues with Snap](https://github.com/casperdcl/cli/issues/7)
|
|||
|
||||
### Arch Linux
|
||||
|
||||
Arch Linux users can install from the [community repo][arch linux repo]:
|
||||
Arch Linux users can install from the [extra repo][arch linux repo]:
|
||||
|
||||
```bash
|
||||
sudo pacman -S github-cli
|
||||
|
|
@ -234,6 +235,6 @@ sudo xbps-install github-cli
|
|||
```
|
||||
|
||||
[releases page]: https://github.com/cli/cli/releases/latest
|
||||
[arch linux repo]: https://www.archlinux.org/packages/community/x86_64/github-cli
|
||||
[arch linux repo]: https://www.archlinux.org/packages/extra/x86_64/github-cli
|
||||
[arch linux aur]: https://aur.archlinux.org/packages/github-cli-git
|
||||
[^1]: https://wiki.alpinelinux.org/wiki/Package_management#Repository_pinning
|
||||
|
|
|
|||
211
docs/multiple-accounts.md
Normal file
211
docs/multiple-accounts.md
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
# Multiple Accounts with the CLI - v2.40.0
|
||||
|
||||
Since its creation, `gh` has enforced a mapping of one account per host. Functionally, this meant that when targeting a
|
||||
single host (e.g. github.com) each `auth login` would replace the token being used for API requests, and for git
|
||||
operations when `gh` was configured as a git credential manager. Removing this limitation has been a [long requested
|
||||
feature](https://github.com/cli/cli/issues/326), with many community members offering workarounds for a variety of use cases.
|
||||
A particular shoutout to @gabe565 and his long term community support for https://github.com/gabe565/gh-profile in this space.
|
||||
|
||||
With the release of `v2.40.0`, `gh` has begun supporting multiple accounts for some use cases on github.com and
|
||||
in GitHub Enterprise. We recognise that there are a number of missing quality of life features, and we've opted
|
||||
not to address the use case of automatic account switching based on some context (e.g. `pwd`, `git remote`).
|
||||
However, we hope many of those using these custom solutions will now find it easier to obtain and update tokens (via the standard
|
||||
OAuth flow rather than as a PAT), and to store them securely in the system keyring managed by `gh`.
|
||||
|
||||
We are by no means excluding these things from ever being native to `gh` but we wanted to ship this MVP and get more
|
||||
feedback so that we can iterate on it with the community.
|
||||
|
||||
## What is in scope for this release?
|
||||
|
||||
The support for multiple accounts in this release is focused around `auth login` becoming additive in behaviour.
|
||||
This allows for multiple accounts to be easily switched between using the new `auth switch` command. Switching the "active"
|
||||
user for a host will swap the token used by `gh` for API requests, and for git operations when `gh` was configured as a
|
||||
git credential manager.
|
||||
|
||||
We have extended the `auth logout` command to switch account where possible if the currently active user is the target
|
||||
of the `logout`. Finally we have extended `auth token`, `auth switch`, and `auth logout` with a
|
||||
`--user` flag. This new flag in combination with `--hostname` can be used to disambiguate accounts when running
|
||||
non-interactively.
|
||||
|
||||
Here's an example usage. First, we can see that I have a single account `wilmartin_microsoft` logged in, and
|
||||
`auth status` reports that this is the active account:
|
||||
|
||||
```
|
||||
➜ gh auth status
|
||||
github.com
|
||||
✓ Logged in to github.com account wilmartin_microsoft (keyring)
|
||||
- Active account: true
|
||||
- Git operations protocol: https
|
||||
- Token: gho_************************************
|
||||
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
|
||||
```
|
||||
|
||||
Running `auth login` and proceeding through the browser based OAuth flow as `williammartin`, we can see that
|
||||
`auth status` now reports two accounts under `github.com`, and our new account is now marked as active.
|
||||
|
||||
```
|
||||
➜ gh auth login
|
||||
? What account do you want to log into? GitHub.com
|
||||
? What is your preferred protocol for Git operations on this host? HTTPS
|
||||
? How would you like to authenticate GitHub CLI? Login with a web browser
|
||||
|
||||
! First copy your one-time code: A1F4-3B3C
|
||||
Press Enter to open github.com in your browser...
|
||||
✓ Authentication complete.
|
||||
- gh config set -h github.com git_protocol https
|
||||
✓ Configured git protocol
|
||||
✓ Logged in as williammartin
|
||||
|
||||
➜ gh auth status
|
||||
github.com
|
||||
✓ Logged in to github.com account williammartin (keyring)
|
||||
- Active account: true
|
||||
- Git operations protocol: https
|
||||
- Token: gho_************************************
|
||||
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
|
||||
|
||||
✓ Logged in to github.com account wilmartin_microsoft (keyring)
|
||||
- Active account: false
|
||||
- Git operations protocol: https
|
||||
- Token: gho_************************************
|
||||
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
|
||||
```
|
||||
|
||||
Fetching our username from the API shows that our active token correctly corresponds to `williammartin`:
|
||||
|
||||
```
|
||||
➜ gh api /user | jq .login
|
||||
"williammartin"
|
||||
```
|
||||
|
||||
Now we can easily switch accounts using `gh auth switch`, and hitting the API shows that the active token has been
|
||||
changed:
|
||||
|
||||
```
|
||||
➜ gh auth switch
|
||||
✓ Switched active account for github.com to wilmartin_microsoft
|
||||
|
||||
➜ gh api /user | jq .login
|
||||
"wilmartin_microsoft"
|
||||
```
|
||||
|
||||
We can use `gh auth token --user` to get a specific token for a user (which should be handy for automated switching
|
||||
solutions):
|
||||
|
||||
```
|
||||
➜ GH_TOKEN=$(gh auth token --user williammartin) gh api /user | jq .login
|
||||
"williammartin"
|
||||
```
|
||||
|
||||
Finally, running `gh auth logout` presents a prompt when there are multiple choices for logout, and switches account
|
||||
if there are any remaining logged into the host:
|
||||
|
||||
```
|
||||
➜ gh auth logout
|
||||
? What account do you want to log out of? wilmartin_microsoft (github.com)
|
||||
✓ Logged out of github.com account wilmartin_microsoft
|
||||
✓ Switched active account for github.com to williammartin
|
||||
```
|
||||
|
||||
## What is out of scope for this release?
|
||||
|
||||
As mentioned above, we know that this only addresses some of the requests around supporting multiple accounts. While
|
||||
these are not out of scope forever, for this release some of the big things we have intentionally not included are:
|
||||
* Automatic account switching based on some context (e.g. `pwd`, `git remote`)
|
||||
* Automatic configuration of git config such as `user.name` and `user.email` when switching
|
||||
* User level configuration e.g. `williammartin` uses `vim` but `wilmartin_microsoft` uses `emacs`
|
||||
|
||||
## What are some sharp edges in this release?
|
||||
|
||||
As in any MVP there are going to be some sharp edges that need to be smoothed out over time. Here are a list of known
|
||||
sharp edges in this release.
|
||||
|
||||
### Data Migration
|
||||
|
||||
The trickiest piece of this work was that the `hosts.yml` file only supported a mapping of one-to-one in the host to
|
||||
account relationship. Having persistent data on disk that required a schema change presented a compatibility challenge
|
||||
both backwards for those who use [`go-gh`](https://github.com/cli/go-gh/) outside of `gh`, and forward for `gh` itself
|
||||
where we try to ensure that it's possible to use older versions in case we accidentally make a breaking change for users.
|
||||
|
||||
As such, from this release, running any command will attempt to migrate this data into a new format, and will
|
||||
additionally add a `version` field into the `config.yml` to aid in our future maintainability. While we have tried
|
||||
to maintain forward compatibility (except in one edge case outlined below), and in the worst case you should be able
|
||||
to remove these files and start from scratch, if you are concerned about the data in these files, we advise you to take
|
||||
a backup.
|
||||
|
||||
#### Forward Compatibility Exclusion
|
||||
|
||||
There is one known case using `--insecure-storage` that we don't maintain complete forward and backward compatibility.
|
||||
This occurs if you `auth login --insecure-storage`, upgrade to this release (which performs the data migration), run
|
||||
`auth login --insecure-storage` again on an older release, then at some time later use `auth switch` to make this
|
||||
account active. The symptom here would be usage of an older token (which may for example have different scopes).
|
||||
|
||||
This occurs because we will only perform the data migration once, moving the original insecure token to a place where
|
||||
it would later be used by `auth switch`.
|
||||
|
||||
#### Immutable Config Users
|
||||
|
||||
Some of our users lean on tools to manage their application configuration in an immutable manner for example using
|
||||
https://github.com/nix-community/home-manager. These users will hit an error when we attempt to persist the new
|
||||
`version` field to the `config.yml`. They will need to ensure that the `home-manager` configuration scripts are updated
|
||||
to add `version: 1`.
|
||||
|
||||
See https://github.com/nix-community/home-manager/issues/4744 for more details.
|
||||
|
||||
### Auth Refresh
|
||||
|
||||
Although this has always been possible, the multi account flow increases the likelihood of doing something surprising
|
||||
with `auth refresh`. This command allows for a token to be updated with additional or fewer scopes. For example,
|
||||
in the following example we add the `read:project` scope to the scopes for our currently active user `williammartin`,
|
||||
and proceed through the OAuth browser flow as `williammartin`:
|
||||
|
||||
```
|
||||
➜ gh auth refresh -s read:project
|
||||
? What account do you want to refresh auth for? github.com
|
||||
|
||||
! First copy your one-time code: E79E-5FA2
|
||||
Press Enter to open github.com in your browser...
|
||||
✓ Authentication complete.
|
||||
|
||||
➜ gh auth status
|
||||
github.com
|
||||
✓ Logged in to github.com account williammartin (keyring)
|
||||
- Active account: true
|
||||
- Git operations protocol: https
|
||||
- Token: gho_************************************
|
||||
- Token scopes: 'gist', 'read:org', 'read:project', 'repo', 'workflow'
|
||||
|
||||
✓ Logged in to github.com account wilmartin_microsoft (keyring)
|
||||
|
||||
✓ Logged in to github.com account wilmartin_microsoft (keyring)
|
||||
- Active account: false
|
||||
- Git operations protocol: https
|
||||
- Token: gho_************************************
|
||||
- Token scopes: 'gist', 'read:org', 'repo', 'workflow'
|
||||
```
|
||||
|
||||
However, what happens if I try to remove the `workflow` scope from my active user `williammartin` but proceed through
|
||||
the OAuth browser flow as `wilmartin_microsoft`?
|
||||
|
||||
```
|
||||
➜ gh auth refresh -r workflow
|
||||
|
||||
! First copy your one-time code: EEA3-091C
|
||||
Press Enter to open github.com in your browser...
|
||||
error refreshing credentials for williammartin, received credentials for wilmartin_microsoft, did you use the correct account in the browser?
|
||||
```
|
||||
|
||||
When adding or removing scopes for a user, the CLI gets the scopes for the current token and then requests a new token with the requested amended scopes. Unfortunately, when we go through the account switcher flow as a different user, we end up getting a token for the wrong user with surprising scopes. We don't believe that starting and ending a `refresh` as different accounts is
|
||||
a use case we wish to support and has the potential for misuse. As such, we have begun erroring in this case.
|
||||
|
||||
Note that a token has still been minted on the platform but `gh` will refuse to store it. We are investigating
|
||||
alternative approaches with the platform team to put some better guardrails in place earlier in the flow.
|
||||
|
||||
### Account Switcher on GitHub Enterprise
|
||||
|
||||
When using `auth login` with github.com, if a user has multiple accounts in the browser, they should be presented
|
||||
with an interstitial page that allows for proceeding as any of their accounts. However, for Device Control Flow OAuth
|
||||
flows, this feature has not yet made it into GHES.
|
||||
|
||||
For the moment, if you have multiple accounts on GHES that you wish to log in as, you will need to ensure that you
|
||||
are authenticated as the correct user in the browser before running `auth login`.
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Installation from source
|
||||
|
||||
1. Verify that you have Go 1.21+ installed
|
||||
1. Verify that you have Go 1.22+ installed
|
||||
|
||||
```sh
|
||||
$ go version
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ triage role. The initial expectation is that the person in the role for the week
|
|||
|
||||
## Expectations for incoming issues
|
||||
|
||||
All incoming issues need either an `enhancement`, `bug`, or `docs` label.
|
||||
Review and label [open issues missing either the `enhancement`, `bug`, or `docs` label](https://github.com/cli/cli/issues?q=is%3Aopen+is%3Aissue+-label%3Abug%2Cenhancement%2Cdocs+).
|
||||
|
||||
To be considered triaged, `enhancement` issues require at least one of the following additional labels:
|
||||
|
||||
|
|
|
|||
138
git/client.go
138
git/client.go
|
|
@ -20,6 +20,26 @@ import (
|
|||
|
||||
var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
|
||||
|
||||
// This regexp exists to match lines of the following form:
|
||||
// 6a6872b918c601a0e730710ad8473938a7516d30\u0000title 1\u0000Body 1\u0000\n
|
||||
// 7a6872b918c601a0e730710ad8473938a7516d31\u0000title 2\u0000Body 2\u0000
|
||||
//
|
||||
// This is the format we use when collecting commit information,
|
||||
// with null bytes as separators. Using null bytes this way allows for us
|
||||
// to easily maintain newlines that might be in the body.
|
||||
//
|
||||
// The ?m modifier is the multi-line modifier, meaning that ^ and $
|
||||
// match the beginning and end of lines, respectively.
|
||||
//
|
||||
// The [\S\s] matches any whitespace or non-whitespace character,
|
||||
// which is different from .* because it allows for newlines as well.
|
||||
//
|
||||
// The ? following .* and [\S\s] is a lazy modifier, meaning that it will
|
||||
// match as few characters as possible while still satisfying the rest of the regexp.
|
||||
// This is important because it allows us to match the first null byte after the title and body,
|
||||
// rather than the last null byte in the entire string.
|
||||
var commitLogRE = regexp.MustCompile(`(?m)^[0-9a-fA-F]{7,40}\x00.*?\x00[\S\s]*?\x00$`)
|
||||
|
||||
type errWithExitCode interface {
|
||||
ExitCode() int
|
||||
}
|
||||
|
|
@ -228,7 +248,12 @@ func (c *Client) UncommittedChangeCount(ctx context.Context) (int, error) {
|
|||
}
|
||||
|
||||
func (c *Client) Commits(ctx context.Context, baseRef, headRef string) ([]*Commit, error) {
|
||||
args := []string{"-c", "log.ShowSignature=false", "log", "--pretty=format:%H,%s", "--cherry", fmt.Sprintf("%s...%s", baseRef, headRef)}
|
||||
// The formatting directive %x00 indicates that git should include the null byte as a separator.
|
||||
// We use this because it is not a valid character to include in a commit message. Previously,
|
||||
// commas were used here but when we Split on them, we would get incorrect results if commit titles
|
||||
// happened to contain them.
|
||||
// https://git-scm.com/docs/pretty-formats#Documentation/pretty-formats.txt-emx00em
|
||||
args := []string{"-c", "log.ShowSignature=false", "log", "--pretty=format:%H%x00%s%x00%b%x00", "--cherry", fmt.Sprintf("%s...%s", baseRef, headRef)}
|
||||
cmd, err := c.Command(ctx, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -237,22 +262,33 @@ func (c *Client) Commits(ctx context.Context, baseRef, headRef string) ([]*Commi
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commits := []*Commit{}
|
||||
sha := 0
|
||||
title := 1
|
||||
for _, line := range outputLines(out) {
|
||||
split := strings.SplitN(line, ",", 2)
|
||||
if len(split) != 2 {
|
||||
continue
|
||||
}
|
||||
commitLogs := commitLogRE.FindAllString(string(out), -1)
|
||||
for _, commitLog := range commitLogs {
|
||||
// Each line looks like this:
|
||||
// 6a6872b918c601a0e730710ad8473938a7516d30\u0000title 1\u0000Body 1\u0000\n
|
||||
|
||||
// Or with an optional body:
|
||||
// 6a6872b918c601a0e730710ad8473938a7516d30\u0000title 1\u0000\u0000\n
|
||||
|
||||
// Therefore after splitting we will have:
|
||||
// ["6a6872b918c601a0e730710ad8473938a7516d30", "title 1", "Body 1", ""]
|
||||
|
||||
// Or with an optional body:
|
||||
// ["6a6872b918c601a0e730710ad8473938a7516d30", "title 1", "", ""]
|
||||
commitLogParts := strings.Split(commitLog, "\u0000")
|
||||
commits = append(commits, &Commit{
|
||||
Sha: split[sha],
|
||||
Title: split[title],
|
||||
Sha: commitLogParts[0],
|
||||
Title: commitLogParts[1],
|
||||
Body: commitLogParts[2],
|
||||
})
|
||||
}
|
||||
|
||||
if len(commits) == 0 {
|
||||
return nil, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef)
|
||||
}
|
||||
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
|
|
@ -322,6 +358,19 @@ func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg Branc
|
|||
return
|
||||
}
|
||||
|
||||
func (c *Client) DeleteLocalTag(ctx context.Context, tag string) error {
|
||||
args := []string{"tag", "-d", tag}
|
||||
cmd, err := c.Command(ctx, args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cmd.Output()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteLocalBranch(ctx context.Context, branch string) error {
|
||||
args := []string{"branch", "-D", branch}
|
||||
cmd, err := c.Command(ctx, args...)
|
||||
|
|
@ -459,6 +508,39 @@ func (c *Client) SetRemoteBranches(ctx context.Context, remote string, refspec s
|
|||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string) (*Remote, error) {
|
||||
args := []string{"remote", "add"}
|
||||
for _, branch := range trackingBranches {
|
||||
args = append(args, "-t", branch)
|
||||
}
|
||||
args = append(args, name, urlStr)
|
||||
cmd, err := c.Command(ctx, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := cmd.Output(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var urlParsed *url.URL
|
||||
if strings.HasPrefix(urlStr, "https") {
|
||||
urlParsed, err = url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
urlParsed, err = ParseURL(urlStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
remote := &Remote{
|
||||
Name: name,
|
||||
FetchURL: urlParsed,
|
||||
PushURL: urlParsed,
|
||||
}
|
||||
return remote, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
|
@ -528,42 +610,6 @@ func (c *Client) Clone(ctx context.Context, cloneURL string, args []string, mods
|
|||
return target, nil
|
||||
}
|
||||
|
||||
func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string, mods ...CommandModifier) (*Remote, error) {
|
||||
args := []string{"remote", "add"}
|
||||
for _, branch := range trackingBranches {
|
||||
args = append(args, "-t", branch)
|
||||
}
|
||||
args = append(args, name, urlStr)
|
||||
cmd, err := c.Command(ctx, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, mod := range mods {
|
||||
mod(cmd)
|
||||
}
|
||||
if _, err := cmd.Output(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var urlParsed *url.URL
|
||||
if strings.HasPrefix(urlStr, "https") {
|
||||
urlParsed, err = url.Parse(urlStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
urlParsed, err = ParseURL(urlStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
remote := &Remote{
|
||||
Name: name,
|
||||
FetchURL: urlParsed,
|
||||
PushURL: urlParsed,
|
||||
}
|
||||
return remote, nil
|
||||
}
|
||||
|
||||
func resolveGitPath() (string, error) {
|
||||
path, err := safeexec.LookPath("git")
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package git
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
|
@ -13,6 +14,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClientCommand(t *testing.T) {
|
||||
|
|
@ -458,57 +460,233 @@ func TestClientUncommittedChangeCount(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
type stubbedCommit struct {
|
||||
Sha string
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
type stubbedCommitsCommandData struct {
|
||||
ExitStatus int
|
||||
|
||||
ErrMsg string
|
||||
|
||||
Commits []stubbedCommit
|
||||
}
|
||||
|
||||
func TestClientCommits(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmdExitStatus int
|
||||
cmdStdout string
|
||||
cmdStderr string
|
||||
wantCmdArgs string
|
||||
wantCommits []*Commit
|
||||
wantErrorMsg string
|
||||
name string
|
||||
testData stubbedCommitsCommandData
|
||||
wantCmdArgs string
|
||||
wantCommits []*Commit
|
||||
wantErrorMsg string
|
||||
}{
|
||||
{
|
||||
name: "get commits",
|
||||
cmdStdout: "6a6872b918c601a0e730710ad8473938a7516d30,testing testability test",
|
||||
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H,%s --cherry SHA1...SHA2`,
|
||||
name: "single commit no body",
|
||||
testData: stubbedCommitsCommandData{
|
||||
Commits: []stubbedCommit{
|
||||
{
|
||||
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
|
||||
Title: "testing testability test",
|
||||
Body: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
|
||||
wantCommits: []*Commit{{
|
||||
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
|
||||
Title: "testing testability test",
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "no commits between SHAs",
|
||||
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H,%s --cherry SHA1...SHA2`,
|
||||
name: "single commit with body",
|
||||
testData: stubbedCommitsCommandData{
|
||||
Commits: []stubbedCommit{
|
||||
{
|
||||
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
|
||||
Title: "testing testability test",
|
||||
Body: "This is the body",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
|
||||
wantCommits: []*Commit{{
|
||||
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
|
||||
Title: "testing testability test",
|
||||
Body: "This is the body",
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "multiple commits with bodies",
|
||||
testData: stubbedCommitsCommandData{
|
||||
Commits: []stubbedCommit{
|
||||
{
|
||||
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
|
||||
Title: "testing testability test",
|
||||
Body: "This is the body",
|
||||
},
|
||||
{
|
||||
Sha: "7a6872b918c601a0e730710ad8473938a7516d31",
|
||||
Title: "testing testability test 2",
|
||||
Body: "This is the body 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
|
||||
wantCommits: []*Commit{
|
||||
{
|
||||
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
|
||||
Title: "testing testability test",
|
||||
Body: "This is the body",
|
||||
},
|
||||
{
|
||||
Sha: "7a6872b918c601a0e730710ad8473938a7516d31",
|
||||
Title: "testing testability test 2",
|
||||
Body: "This is the body 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple commits mixed bodies",
|
||||
testData: stubbedCommitsCommandData{
|
||||
Commits: []stubbedCommit{
|
||||
{
|
||||
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
|
||||
Title: "testing testability test",
|
||||
},
|
||||
{
|
||||
Sha: "7a6872b918c601a0e730710ad8473938a7516d31",
|
||||
Title: "testing testability test 2",
|
||||
Body: "This is the body 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
|
||||
wantCommits: []*Commit{
|
||||
{
|
||||
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
|
||||
Title: "testing testability test",
|
||||
},
|
||||
{
|
||||
Sha: "7a6872b918c601a0e730710ad8473938a7516d31",
|
||||
Title: "testing testability test 2",
|
||||
Body: "This is the body 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple commits newlines in bodies",
|
||||
testData: stubbedCommitsCommandData{
|
||||
Commits: []stubbedCommit{
|
||||
{
|
||||
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
|
||||
Title: "testing testability test",
|
||||
Body: "This is the body\nwith a newline",
|
||||
},
|
||||
{
|
||||
Sha: "7a6872b918c601a0e730710ad8473938a7516d31",
|
||||
Title: "testing testability test 2",
|
||||
Body: "This is the body 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
|
||||
wantCommits: []*Commit{
|
||||
{
|
||||
Sha: "6a6872b918c601a0e730710ad8473938a7516d30",
|
||||
Title: "testing testability test",
|
||||
Body: "This is the body\nwith a newline",
|
||||
},
|
||||
{
|
||||
Sha: "7a6872b918c601a0e730710ad8473938a7516d31",
|
||||
Title: "testing testability test 2",
|
||||
Body: "This is the body 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no commits between SHAs",
|
||||
testData: stubbedCommitsCommandData{
|
||||
Commits: []stubbedCommit{},
|
||||
},
|
||||
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
|
||||
wantErrorMsg: "could not find any commits between SHA1 and SHA2",
|
||||
},
|
||||
{
|
||||
name: "git error",
|
||||
cmdExitStatus: 1,
|
||||
cmdStderr: "git error message",
|
||||
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H,%s --cherry SHA1...SHA2`,
|
||||
wantErrorMsg: "failed to run git: git error message",
|
||||
name: "git error",
|
||||
testData: stubbedCommitsCommandData{
|
||||
ErrMsg: "git error message",
|
||||
ExitStatus: 1,
|
||||
},
|
||||
wantCmdArgs: `path/to/git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry SHA1...SHA2`,
|
||||
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)
|
||||
cmd, cmdCtx := createCommitsCommandContext(t, tt.testData)
|
||||
client := Client{
|
||||
GitPath: "path/to/git",
|
||||
commandContext: cmdCtx,
|
||||
}
|
||||
commits, err := client.Commits(context.Background(), "SHA1", "SHA2")
|
||||
assert.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
|
||||
require.Equal(t, tt.wantCmdArgs, strings.Join(cmd.Args[3:], " "))
|
||||
if tt.wantErrorMsg != "" {
|
||||
assert.EqualError(t, err, tt.wantErrorMsg)
|
||||
require.EqualError(t, err, tt.wantErrorMsg)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.wantCommits, commits)
|
||||
require.Equal(t, tt.wantCommits, commits)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitsHelperProcess(t *testing.T) {
|
||||
if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
|
||||
var td stubbedCommitsCommandData
|
||||
_ = json.Unmarshal([]byte(os.Getenv("GH_COMMITS_TEST_DATA")), &td)
|
||||
|
||||
if td.ErrMsg != "" {
|
||||
fmt.Fprint(os.Stderr, td.ErrMsg)
|
||||
} else {
|
||||
var sb strings.Builder
|
||||
for _, commit := range td.Commits {
|
||||
sb.WriteString(commit.Sha)
|
||||
sb.WriteString("\u0000")
|
||||
sb.WriteString(commit.Title)
|
||||
sb.WriteString("\u0000")
|
||||
sb.WriteString(commit.Body)
|
||||
sb.WriteString("\u0000")
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
fmt.Fprint(os.Stdout, sb.String())
|
||||
}
|
||||
|
||||
os.Exit(td.ExitStatus)
|
||||
}
|
||||
|
||||
func createCommitsCommandContext(t *testing.T, testData stubbedCommitsCommandData) (*exec.Cmd, commandCtx) {
|
||||
t.Helper()
|
||||
|
||||
b, err := json.Marshal(testData)
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := exec.CommandContext(context.Background(), os.Args[0], "-test.run=TestCommitsHelperProcess", "--")
|
||||
cmd.Env = []string{
|
||||
"GH_WANT_HELPER_PROCESS=1",
|
||||
"GH_COMMITS_TEST_DATA=" + string(b),
|
||||
}
|
||||
return cmd, func(ctx context.Context, exe string, args ...string) *exec.Cmd {
|
||||
cmd.Args = append(cmd.Args, exe)
|
||||
cmd.Args = append(cmd.Args, args...)
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientLastCommit(t *testing.T) {
|
||||
client := Client{
|
||||
RepoDir: "./fixtures/simple.git",
|
||||
|
|
@ -558,6 +736,45 @@ func TestClientReadBranchConfig(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestClientDeleteLocalTag(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cmdExitStatus int
|
||||
cmdStdout string
|
||||
cmdStderr string
|
||||
wantCmdArgs string
|
||||
wantErrorMsg string
|
||||
}{
|
||||
{
|
||||
name: "delete local tag",
|
||||
wantCmdArgs: `path/to/git tag -d v1.0`,
|
||||
},
|
||||
{
|
||||
name: "git error",
|
||||
cmdExitStatus: 1,
|
||||
cmdStderr: "git error message",
|
||||
wantCmdArgs: `path/to/git tag -d v1.0`,
|
||||
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.DeleteLocalTag(context.Background(), "v1.0")
|
||||
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 TestClientDeleteLocalBranch(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ func (r TrackingRef) String() string {
|
|||
type Commit struct {
|
||||
Sha string
|
||||
Title string
|
||||
Body string
|
||||
}
|
||||
|
||||
type BranchConfig struct {
|
||||
|
|
|
|||
23
git/url.go
23
git/url.go
|
|
@ -26,7 +26,7 @@ func isPossibleProtocol(u string) bool {
|
|||
}
|
||||
|
||||
// ParseURL normalizes git remote urls
|
||||
func ParseURL(rawURL string) (u *url.URL, err error) {
|
||||
func ParseURL(rawURL string) (*url.URL, error) {
|
||||
if !isPossibleProtocol(rawURL) &&
|
||||
strings.ContainsRune(rawURL, ':') &&
|
||||
// not a Windows path
|
||||
|
|
@ -35,30 +35,27 @@ func ParseURL(rawURL string) (u *url.URL, err error) {
|
|||
rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1)
|
||||
}
|
||||
|
||||
u, err = url.Parse(rawURL)
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if u.Scheme == "git+ssh" {
|
||||
switch u.Scheme {
|
||||
case "git+https":
|
||||
u.Scheme = "https"
|
||||
case "git+ssh":
|
||||
u.Scheme = "ssh"
|
||||
}
|
||||
|
||||
if u.Scheme == "git+https" {
|
||||
u.Scheme = "https"
|
||||
}
|
||||
|
||||
if u.Scheme != "ssh" {
|
||||
return
|
||||
return u, nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(u.Path, "//") {
|
||||
u.Path = strings.TrimPrefix(u.Path, "/")
|
||||
}
|
||||
|
||||
if idx := strings.Index(u.Host, ":"); idx >= 0 {
|
||||
u.Host = u.Host[0:idx]
|
||||
}
|
||||
u.Host = strings.TrimSuffix(u.Host, ":"+u.Port())
|
||||
|
||||
return
|
||||
return u, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
package git
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
|
@ -56,9 +61,7 @@ func TestIsURL(t *testing.T) {
|
|||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := IsURL(tt.url); got != tt.want {
|
||||
t.Errorf("IsURL() = %v, want %v", got, tt.want)
|
||||
}
|
||||
assert.Equal(t, tt.want, IsURL(tt.url))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -126,6 +129,26 @@ func TestParseURL(t *testing.T) {
|
|||
Path: "/owner/repo.git",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ssh, ipv6",
|
||||
url: "ssh://git@[::1]/owner/repo.git",
|
||||
want: url{
|
||||
Scheme: "ssh",
|
||||
User: "git",
|
||||
Host: "[::1]",
|
||||
Path: "/owner/repo.git",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ssh with port, ipv6",
|
||||
url: "ssh://git@[::1]:22/owner/repo.git",
|
||||
want: url{
|
||||
Scheme: "ssh",
|
||||
User: "git",
|
||||
Host: "[::1]",
|
||||
Path: "/owner/repo.git",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "git+ssh",
|
||||
url: "git+ssh://example.com/owner/repo.git",
|
||||
|
|
@ -196,25 +219,24 @@ func TestParseURL(t *testing.T) {
|
|||
Path: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fails to parse",
|
||||
url: "ssh://git@[/tmp/git-repo",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
u, err := ParseURL(tt.url)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("got error: %v", err)
|
||||
}
|
||||
if u.Scheme != tt.want.Scheme {
|
||||
t.Errorf("expected scheme %q, got %q", tt.want.Scheme, u.Scheme)
|
||||
}
|
||||
if u.User.Username() != tt.want.User {
|
||||
t.Errorf("expected user %q, got %q", tt.want.User, u.User.Username())
|
||||
}
|
||||
if u.Host != tt.want.Host {
|
||||
t.Errorf("expected host %q, got %q", tt.want.Host, u.Host)
|
||||
}
|
||||
if u.Path != tt.want.Path {
|
||||
t.Errorf("expected path %q, got %q", tt.want.Path, u.Path)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, u.Scheme, tt.want.Scheme)
|
||||
assert.Equal(t, u.User.Username(), tt.want.User)
|
||||
assert.Equal(t, u.Host, tt.want.Host)
|
||||
assert.Equal(t, u.Path, tt.want.Path)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
176
go.mod
176
go.mod
|
|
@ -1,90 +1,168 @@
|
|||
module github.com/cli/cli/v2
|
||||
|
||||
go 1.21
|
||||
go 1.22
|
||||
|
||||
toolchain go1.22.2
|
||||
|
||||
require (
|
||||
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.1
|
||||
github.com/charmbracelet/glamour v0.6.0
|
||||
github.com/charmbracelet/lipgloss v0.5.0
|
||||
github.com/cli/go-gh/v2 v2.3.0
|
||||
github.com/charmbracelet/glamour v0.7.0
|
||||
github.com/charmbracelet/lipgloss v0.10.1-0.20240413172830-d0be07ea6b9c
|
||||
github.com/cli/go-gh/v2 v2.9.0
|
||||
github.com/cli/oauth v1.0.1
|
||||
github.com/cli/safeexec v1.0.1
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2
|
||||
github.com/creack/pty v1.1.18
|
||||
github.com/gabriel-vasile/mimetype v1.4.2
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4
|
||||
github.com/creack/pty v1.1.21
|
||||
github.com/distribution/reference v0.5.0
|
||||
github.com/gabriel-vasile/mimetype v1.4.3
|
||||
github.com/gdamore/tcell/v2 v2.5.4
|
||||
github.com/google/go-cmp v0.5.9
|
||||
github.com/google/go-cmp v0.6.0
|
||||
github.com/google/go-containerregistry v0.19.1
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
github.com/gorilla/websocket v1.4.2
|
||||
github.com/gorilla/websocket v1.5.1
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/hashicorp/go-version v1.3.0
|
||||
github.com/henvic/httpretty v0.1.2
|
||||
github.com/henvic/httpretty v0.1.3
|
||||
github.com/in-toto/in-toto-golang v0.9.0
|
||||
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.19
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
|
||||
github.com/microsoft/dev-tunnels v0.0.25
|
||||
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38
|
||||
github.com/opentracing/opentracing-go v1.1.0
|
||||
github.com/opentracing/opentracing-go v1.2.0
|
||||
github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d
|
||||
github.com/shurcooL/githubv4 v0.0.0-20230704064427-599ae7bbf278
|
||||
github.com/sourcegraph/jsonrpc2 v0.1.0
|
||||
github.com/spf13/cobra v1.6.1
|
||||
github.com/sigstore/protobuf-specs v0.3.2
|
||||
github.com/sigstore/sigstore-go v0.3.0
|
||||
github.com/spf13/cobra v1.8.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/stretchr/testify v1.8.4
|
||||
github.com/zalando/go-keyring v0.2.3
|
||||
golang.org/x/crypto v0.12.0
|
||||
golang.org/x/sync v0.1.0
|
||||
golang.org/x/term v0.11.0
|
||||
golang.org/x/text v0.12.0
|
||||
google.golang.org/grpc v1.53.0
|
||||
google.golang.org/protobuf v1.28.1
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/zalando/go-keyring v0.2.4
|
||||
golang.org/x/crypto v0.22.0
|
||||
golang.org/x/sync v0.6.0
|
||||
golang.org/x/term v0.19.0
|
||||
golang.org/x/text v0.14.0
|
||||
google.golang.org/grpc v1.62.2
|
||||
google.golang.org/protobuf v1.34.1
|
||||
gopkg.in/h2non/gock.v1 v1.1.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma v0.10.0 // indirect
|
||||
github.com/alessio/shellescape v1.4.1 // indirect
|
||||
github.com/aymanbagabas/go-osc52 v1.0.3 // indirect
|
||||
github.com/alecthomas/chroma/v2 v2.8.0 // indirect
|
||||
github.com/alessio/shellescape v1.4.2 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // 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/blang/semver v3.5.1+incompatible // indirect
|
||||
github.com/charmbracelet/x/exp/term v0.0.0-20240425164147-ba2a9512b05f // indirect
|
||||
github.com/cli/browser v1.3.0 // indirect
|
||||
github.com/cli/shurcooL-graphql v0.0.4 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.14.3 // indirect
|
||||
github.com/cyberphone/json-canonicalization v0.0.0-20220623050100-57a0ce2678a7 // indirect
|
||||
github.com/danieljoos/wincred v1.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect
|
||||
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect
|
||||
github.com/dlclark/regexp2 v1.4.0 // indirect
|
||||
github.com/fatih/color v1.7.0 // indirect
|
||||
github.com/docker/cli v24.0.0+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.2+incompatible // indirect
|
||||
github.com/docker/docker v24.0.9+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.7.0 // indirect
|
||||
github.com/fatih/color v1.14.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/gdamore/encoding v1.0.0 // indirect
|
||||
github.com/go-chi/chi v4.1.2+incompatible // indirect
|
||||
github.com/go-logr/logr v1.4.1 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-openapi/analysis v0.23.0 // indirect
|
||||
github.com/go-openapi/errors v0.22.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/loads v0.22.0 // indirect
|
||||
github.com/go-openapi/runtime v0.28.0 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/strfmt v0.23.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-openapi/validate v0.24.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
github.com/golang/protobuf v1.5.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/certificate-transparency-go v1.1.8 // indirect
|
||||
github.com/google/uuid v1.6.0 // 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.13 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/itchyny/gojq v0.12.15 // indirect
|
||||
github.com/itchyny/timefmt-go v0.1.5 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/klauspost/compress v1.17.4 // indirect
|
||||
github.com/letsencrypt/boulder v0.0.0-20230907030200-6d76a0f91e1e // 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.21 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.13.0 // indirect
|
||||
github.com/muesli/termenv v0.15.2 // indirect
|
||||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
github.com/olekukonko/tablewriter v0.0.5 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.4 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.0-rc5 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rodaine/table v1.0.1 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sassoftware/relic v7.2.1+incompatible // indirect
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect
|
||||
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect
|
||||
github.com/stretchr/objx v0.5.0 // indirect
|
||||
github.com/sigstore/rekor v1.3.6 // indirect
|
||||
github.com/sigstore/sigstore v1.8.3 // indirect
|
||||
github.com/sigstore/timestamp-authority v1.2.2 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/viper v1.18.2 // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/theupdateframework/go-tuf v0.7.0 // indirect
|
||||
github.com/theupdateframework/go-tuf/v2 v2.0.0-20240223092044-1e7978e83f63 // indirect
|
||||
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect
|
||||
github.com/yuin/goldmark v1.5.2 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.1 // indirect
|
||||
golang.org/x/net v0.14.0 // indirect
|
||||
golang.org/x/sys v0.11.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
|
||||
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect
|
||||
github.com/transparency-dev/merkle v0.0.2 // indirect
|
||||
github.com/vbatts/tar-split v0.11.3 // indirect
|
||||
github.com/yuin/goldmark v1.5.4 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.2 // indirect
|
||||
go.mongodb.org/mongo-driver v1.14.0 // indirect
|
||||
go.opentelemetry.io/otel v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.24.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
|
||||
golang.org/x/mod v0.17.0 // indirect
|
||||
golang.org/x/net v0.23.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240311173647-c811ad7063a7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect
|
||||
gopkg.in/go-jose/go-jose.v2 v2.6.3 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
k8s.io/klog/v2 v2.120.1 // indirect
|
||||
)
|
||||
|
||||
replace golang.org/x/crypto => github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03
|
||||
|
|
|
|||
557
go.sum
557
go.sum
|
|
@ -1,247 +1,568 @@
|
|||
cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM=
|
||||
cloud.google.com/go/compute v1.25.0 h1:H1/4SqSUhjPFE7L5ddzHOfY2bCAvjwNRZPNl6Ni5oYU=
|
||||
cloud.google.com/go/compute v1.25.0/go.mod h1:GR7F0ZPZH8EhChlMo9FkLd7eUTwEymjqQagxzilIxIE=
|
||||
cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY=
|
||||
cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA=
|
||||
cloud.google.com/go/iam v1.1.6 h1:bEa06k05IO4f4uJonbB5iAgKTPpABy1ayxaIZV/GHVc=
|
||||
cloud.google.com/go/iam v1.1.6/go.mod h1:O0zxdPeGBoFdWW3HWmBxJsk0pfvNM/p/qa82rWOGTwI=
|
||||
cloud.google.com/go/kms v1.15.8 h1:szIeDCowID8th2i8XE4uRev5PMxQFqW+JjwYxL9h6xs=
|
||||
cloud.google.com/go/kms v1.15.8/go.mod h1:WoUHcDjD9pluCg7pNds131awnH429QGvRM3N/4MyoVs=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg=
|
||||
github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM=
|
||||
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/Azure/azure-sdk-for-go/sdk/azcore v1.10.0 h1:n1DH8TPV4qqPTje2RcUBYwtrTWlabVp4n46+74X2pn4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.10.0/go.mod h1:HDcZnuGbiyppErN6lB+idp4CKhjbc8gwjto6OPpyggM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1 h1:sO0/P7g68FrryJzljemN+6GTssUXdANk6aJ7T1ZxnsQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.5.1/go.mod h1:h8hyGFDsU5HMivxiS2iYFZsgDbU9OnnJ163x5UGVKYo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2 h1:LqbJ/WzJUwBf8UiaSzgX7aMclParm9/5Vgp+TY51uBQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.5.2/go.mod h1:yInRyqWXAuaPrgI7p70+lDDgh3mlBohis29jGMISnmc=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0 h1:DRiANoJTiW6obBQe3SqZizkuV1PEgfiiGivmVocDy64=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.1.0/go.mod h1:qLIye2hwb/ZouqhpSD9Zn3SJipvpEnz1Ywl3VUk9Y0s=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0 h1:D3occbWoio4EBLkbkevetNMAVX197GkzbUMtqjGWn80=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.0.0/go.mod h1:bTSOgj05NGRuHHhQwAdPnYr9TOdNmKlZTgGLL6nyAdI=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
|
||||
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
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=
|
||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
|
||||
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
||||
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
|
||||
github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=
|
||||
github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
|
||||
github.com/aymanbagabas/go-osc52 v1.0.3 h1:DTwqENW7X9arYimJrPeGZcV0ln14sGMt3pHZspWD+Mg=
|
||||
github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
|
||||
github.com/alecthomas/assert/v2 v2.2.1 h1:XivOgYcduV98QCahG8T5XTezV5bylXe+lBxLG2K2ink=
|
||||
github.com/alecthomas/assert/v2 v2.2.1/go.mod h1:pXcQ2Asjp247dahGEmsZ6ru0UVwnkhktn7S0bBDLxvQ=
|
||||
github.com/alecthomas/chroma/v2 v2.8.0 h1:w9WJUjFFmHHB2e8mRpL9jjy3alYDlU0QLDezj1xE264=
|
||||
github.com/alecthomas/chroma/v2 v2.8.0/go.mod h1:yrkMI9807G1ROx13fhe1v6PN2DDeaR73L3d+1nmYQtw=
|
||||
github.com/alecthomas/repr v0.2.0 h1:HAzS41CIzNW5syS8Mf9UwXhNH1J9aix/BvDRf1Ml2Yk=
|
||||
github.com/alecthomas/repr v0.2.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4uEoM0=
|
||||
github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aws/aws-sdk-go v1.51.6 h1:Ld36dn9r7P9IjU8WZSaswQ8Y/XUCRpewim5980DwYiU=
|
||||
github.com/aws/aws-sdk-go v1.51.6/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.26.0 h1:/Ce4OCiM3EkpW7Y+xUnfAFpchU78K7/Ug01sZni9PgA=
|
||||
github.com/aws/aws-sdk-go-v2 v1.26.0/go.mod h1:35hUlJVYd+M++iLI3ALmVwMOyRYMmRqUXpTtRGW+K9I=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.9 h1:gRx/NwpNEFSk+yQlgmk1bmxxvQ5TyJ76CWXs9XScTqg=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.27.9/go.mod h1:dK1FQfpwpql83kbD873E9vz4FyAxuJtR22wzoXn3qq0=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.9 h1:N8s0/7yW+h8qR8WaRlPQeJ6czVMNQVNtNdUqf6cItao=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.9/go.mod h1:446YhIdmSV0Jf/SLafGZalQo+xr2iw7/fzXGDPTU1yQ=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.0 h1:af5YzcLf80tv4Em4jWVD75lpnOHSBkPUZxZfGkrI3HI=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.0/go.mod h1:nQ3how7DMnFMWiU1SpECohgC82fpn4cKZ875NDMmwtA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.4 h1:0ScVK/4qZ8CIW0k8jOeFVsyS/sAiXpYxRBLolMkuLQM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.4/go.mod h1:84KyjNZdHC6QZW08nfHI6yZgPd+qRgaWcYsyLUo3QY8=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.4 h1:sHmMWWX5E7guWEFQ9SVo6A3S4xpPrWnd77a6y4WM6PU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.4/go.mod h1:WjpDrhWisWOIoS9n3nk67A3Ll1vfULJ9Kq6h29HTD48=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1 h1:EyBZibRTVAs6ECHZOw5/wlylS9OcTzwyjeQMudmREjE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.1/go.mod h1:JKpmtYhhPs7D97NL/ltqz7yCkERFW5dOlHyVl66ZYF8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.6 h1:b+E7zIUHMmcB4Dckjpkapoy47W6C9QBv/zoUP+Hn8Kc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.6/go.mod h1:S2fNV0rxrP78NhPbCZeQgY8H9jdDMeGtwcfZIRxzBqU=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.30.0 h1:yS0JkEdV6h9JOo8sy2JSpjX+i7vsKifU8SIeHrqiDhU=
|
||||
github.com/aws/aws-sdk-go-v2/service/kms v1.30.0/go.mod h1:+I8VUUSVD4p5ISQtzpgSva4I8cJ4SQ4b1dcBcof7O+g=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.20.3 h1:mnbuWHOcM70/OFUlZZ5rcdfA8PflGXXiefU/O+1S3+8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.20.3/go.mod h1:5HFu51Elk+4oRBZVxmHrSds5jFXmFj8C3w7DVF2gnrs=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.3 h1:uLq0BKatTmDzWa/Nu4WO0M1AaQDaPpwTKAeByEc6WFM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.3/go.mod h1:b+qdhjnxj8GSR6t5YfphOffeoQSQ1KmpoVVuBn+PWxs=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.5 h1:J/PpTf/hllOjx8Xu9DMflff3FajfLxqM5+tepvVXmxg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.5/go.mod h1:0ih0Z83YDH/QeQ6Ori2yGE2XvWYv/Xm+cZc01LC6oK0=
|
||||
github.com/aws/smithy-go v1.20.1 h1:4SZlSlMr36UEqC7XOyRVb27XMeZubNcBNN+9IgEPIQw=
|
||||
github.com/aws/smithy-go v1.20.1/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
|
||||
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
|
||||
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/v3 v3.2.2 h1:cfUAAO3yvKMYKPrvhDuHSwQnhZNk/RMHKdZqKTxfm6M=
|
||||
github.com/cenkalti/backoff/v3 v3.2.2/go.mod h1:cIeZDE3IrqwwJl6VUwCN6trj1oXrTS4rc0ij+ULvLYs=
|
||||
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.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
|
||||
github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
|
||||
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/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/glamour v0.7.0 h1:2BtKGZ4iVJCDfMF229EzbeR1QRKLWztO9dMtjmqZSng=
|
||||
github.com/charmbracelet/glamour v0.7.0/go.mod h1:jUMh5MeihljJPQbJ/wf4ldw2+yBP59+ctV36jASy7ps=
|
||||
github.com/charmbracelet/lipgloss v0.10.1-0.20240413172830-d0be07ea6b9c h1:0FwZb0wTiyalb8QQlILWyIuh3nF5wok6j9D9oUQwfQY=
|
||||
github.com/charmbracelet/lipgloss v0.10.1-0.20240413172830-d0be07ea6b9c/go.mod h1:EPP2QJ0ectp3zo6gx9f8oJGq8keirqPJ3XpYEI8wrrs=
|
||||
github.com/charmbracelet/x/exp/term v0.0.0-20240425164147-ba2a9512b05f h1:1BXkZqDueTOBECyDoFGRi0xMYgjJ6vvoPIkWyKOwzTc=
|
||||
github.com/charmbracelet/x/exp/term v0.0.0-20240425164147-ba2a9512b05f/go.mod h1:yQqGHmheaQfkqiJWjklPHVAq1dKbk8uGbcoS/lcKCJ0=
|
||||
github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
|
||||
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/v2 v2.3.0 h1:FAQAP4PaWSAJf4VSxFEIYDQ1oBIs+bKB4GXQAiRr2sQ=
|
||||
github.com/cli/go-gh/v2 v2.3.0/go.mod h1:6WBUuf7LUVAc+eXYYX/nYTYURRc6M03K9cJNwBKvwT0=
|
||||
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
|
||||
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
|
||||
github.com/cli/go-gh/v2 v2.9.0 h1:D3lTjEneMYl54M+WjZ+kRPrR5CEJ5BHS05isBPOV3LI=
|
||||
github.com/cli/go-gh/v2 v2.9.0/go.mod h1:MeRoKzXff3ygHu7zP+NVTT+imcHW6p3tpuxHAzRM2xE=
|
||||
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.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/cli/shurcooL-graphql v0.0.4 h1:6MogPnQJLjKkaXPyGqPRXOI2qCsQdqNfUY1QSJu2GuY=
|
||||
github.com/cli/shurcooL-graphql v0.0.4/go.mod h1:3waN4u02FiZivIV+p1y4d0Jo1jc6BViMA73C+sZo2fk=
|
||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
|
||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.14.3 h1:OqlDCK3ZVUO6C3B/5FSkDwbkEETK84kQgEeFwDC+62k=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.14.3/go.mod h1:KY//uOCIkSuNAHhJogcZtrNHdKrA99/FCCRjE3HD36o=
|
||||
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/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
|
||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
|
||||
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
|
||||
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
||||
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/cyberphone/json-canonicalization v0.0.0-20220623050100-57a0ce2678a7 h1:vU+EP9ZuFUCYE0NYLwTSob+3LNEJATzNfP/DC7SWGWI=
|
||||
github.com/cyberphone/json-canonicalization v0.0.0-20220623050100-57a0ce2678a7/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw=
|
||||
github.com/danieljoos/wincred v1.2.1 h1:dl9cBrupW8+r5250DYkYxocLeZ1Y4vB1kxgtjxw8GQs=
|
||||
github.com/danieljoos/wincred v1.2.1/go.mod h1:uGaFL9fDn3OLTvzCGulzE+SzjEe5NGlh5FdCcyfPwps=
|
||||
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc=
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE=
|
||||
github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc=
|
||||
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I=
|
||||
github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y=
|
||||
github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
|
||||
github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
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/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
|
||||
github.com/docker/cli v24.0.0+incompatible h1:0+1VshNwBQzQAx9lOl+OYCTCEAD8fKs/qeXMx3O0wqM=
|
||||
github.com/docker/cli v24.0.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/distribution v2.8.2+incompatible h1:T3de5rq0dB1j30rp0sA2rER+m322EBzniBPB6ZIzuh8=
|
||||
github.com/docker/distribution v2.8.2+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker v24.0.9+incompatible h1:HPGzNmwfLZWdxHqK9/II92pyi1EpYKsAqcl4G0Of9v0=
|
||||
github.com/docker/docker v24.0.9+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker-credential-helpers v0.7.0 h1:xtCHsjxogADNZcdv1pKUHXryefjlVRqWqIhk/uXJp0A=
|
||||
github.com/docker/docker-credential-helpers v0.7.0/go.mod h1:rETQfLdHNT3foU5kuNkFR1R1V12OJRRO5lzt2D1b5X0=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
|
||||
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||
github.com/gdamore/tcell/v2 v2.5.4 h1:TGU4tSjD3sCL788vFNeJnTdzpNKIw1H5dgLnJRQVv/k=
|
||||
github.com/gdamore/tcell/v2 v2.5.4/go.mod h1:dZgRy5v4iMobMEcWNYBtREnDZAT9DYmfqIkrgEMxLyw=
|
||||
github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
|
||||
github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
|
||||
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
|
||||
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
|
||||
github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=
|
||||
github.com/go-openapi/errors v0.22.0 h1:c4xY/OLxUBSTiepAg3j/MHuAv5mJhnf53LLMWFB+u/w=
|
||||
github.com/go-openapi/errors v0.22.0/go.mod h1:J3DmZScxCDufmIMsdOuDHxJbdOGC0xtUynjIx092vXE=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
||||
github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco=
|
||||
github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs=
|
||||
github.com/go-openapi/runtime v0.28.0 h1:gpPPmWSNGo214l6n8hzdXYhPuJcGtziTOgUpvsFWGIQ=
|
||||
github.com/go-openapi/runtime v0.28.0/go.mod h1:QN7OzcS+XuYmkQLw05akXk0jRH/eZ3kb18+1KwW9gyc=
|
||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||
github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c=
|
||||
github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
|
||||
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
|
||||
github.com/go-test/deep v1.1.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
|
||||
github.com/go-test/deep v1.1.0/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
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/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/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/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/certificate-transparency-go v1.1.8 h1:LGYKkgZF7satzgTak9R4yzfJXEeYVAjV6/EAEJOf1to=
|
||||
github.com/google/certificate-transparency-go v1.1.8/go.mod h1:bV/o8r0TBKRf1X//iiiSgWrvII4d7/8OiA+3vG26gI8=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-containerregistry v0.19.1 h1:yMQ62Al6/V0Z7CqIrrS1iYoA5/oQCm88DeNujc7C1KY=
|
||||
github.com/google/go-containerregistry v0.19.1/go.mod h1:YCMFNQeeXeLF+dnhhWkqDItx/JSkH01j1Kis4PsjzFI=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o=
|
||||
github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
|
||||
github.com/google/tink/go v1.7.0 h1:6Eox8zONGebBFcCBqkVmt60LaWZa6xg1cl/DwAh/J1w=
|
||||
github.com/google/tink/go v1.7.0/go.mod h1:GAUOd+QE3pgj9q8VKIGTCP33c/B7eb4NhxLcgTJZStM=
|
||||
github.com/google/trillian v1.6.0 h1:jMBeDBIkINFvS2n6oV5maDqfRlxREAc6CW9QYWQ0qT4=
|
||||
github.com/google/trillian v1.6.0/go.mod h1:Yu3nIMITzNhhMJEHjAtp6xKiu+H/iHu2Oq5FjV2mCWI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
|
||||
github.com/googleapis/gax-go/v2 v2.12.3 h1:5/zPPDvw8Q1SuXjrqrZslrqT7dL/uJT2CQii/cLCKqA=
|
||||
github.com/googleapis/gax-go/v2 v2.12.3/go.mod h1:AKloxT6GtNbaLm8QTNSidHUVsHYcBHwWRvkNFJUQcS4=
|
||||
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/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||
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/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
|
||||
github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c=
|
||||
github.com/hashicorp/go-hclog v1.5.0/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
|
||||
github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc=
|
||||
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7 h1:UpiO20jno/eV1eVZcxqWnUohyKRe1g8FPV/xH1s/2qs=
|
||||
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.7/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8=
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts=
|
||||
github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4=
|
||||
github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc=
|
||||
github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
|
||||
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/henvic/httpretty v0.1.2 h1:EQo556sO0xeXAjP10eB+BZARMuvkdGqtfeS4Ntjvkiw=
|
||||
github.com/henvic/httpretty v0.1.2/go.mod h1:ViEsly7wgdugYtymX54pYp6Vv2wqZmNHayJ6q8tlKCc=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hashicorp/vault/api v1.12.2 h1:7YkCTE5Ni90TcmYHDBExdt4WGJxhpzaHqR6uGbQb/rE=
|
||||
github.com/hashicorp/vault/api v1.12.2/go.mod h1:LSGf1NGT1BnvFFnKVtnvcaLBM2Lz+gJdpL6HUYed8KE=
|
||||
github.com/henvic/httpretty v0.1.3 h1:4A6vigjz6Q/+yAfTD4wqipCv+Px69C7Th/NhT0ApuU8=
|
||||
github.com/henvic/httpretty v0.1.3/go.mod h1:UUEv7c2kHZ5SPQ51uS3wBpzPDibg2U3Y+IaXyHy5GBg=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
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/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.13 h1:IxyYlHYIlspQHHTE0f3cJF0NKDMfajxViuhBLnHd/QU=
|
||||
github.com/itchyny/gojq v0.12.13/go.mod h1:JzwzAqenfhrPUuwbmEz3nu3JQmFLlQTQMUcOdnu/Sf4=
|
||||
github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM=
|
||||
github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs=
|
||||
github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU=
|
||||
github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/itchyny/gojq v0.12.15 h1:WC1Nxbx4Ifw5U2oQWACYz32JK8G9qxNtHzrvW4KEcqI=
|
||||
github.com/itchyny/gojq v0.12.15/go.mod h1:uWAHCbCIla1jiNxmeT5/B5mOjSdfkCq6p8vxWg+BM10=
|
||||
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/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b h1:ZGiXF8sz7PDk6RgkP+A/SFfUD0ZR/AgG6SpRNEDKZy8=
|
||||
github.com/jedisct1/go-minisign v0.0.0-20211028175153-1c139d1cc84b/go.mod h1:hQmNrgofl+IY/8L+n20H6E6PWBBTokdsv+q49j0QhsU=
|
||||
github.com/jellydator/ttlcache/v3 v3.2.0 h1:6lqVJ8X3ZaUwvzENqPAobDsXNExfUJd61u++uW8a3LE=
|
||||
github.com/jellydator/ttlcache/v3 v3.2.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4=
|
||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs=
|
||||
github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
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/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
|
||||
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/letsencrypt/boulder v0.0.0-20230907030200-6d76a0f91e1e h1:RLTpX495BXToqxpM90Ws4hXEo4Wfh81jr9DX1n/4WOo=
|
||||
github.com/letsencrypt/boulder v0.0.0-20230907030200-6d76a0f91e1e/go.mod h1:EAuqr9VFWxBi9nD5jc/EA2MT1RFty9288TF6zdtYoCU=
|
||||
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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.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.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/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=
|
||||
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
|
||||
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
|
||||
github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
|
||||
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26 h1:xbqSvqzQMeEHCqMi64VAs4d8uy6Mequs3rQ0k/Khz58=
|
||||
github.com/microcosm-cc/bluemonday v1.0.26/go.mod h1:JyzOCs9gkyQyjs+6h10UEVSe02CGwkhd72Xdqh78TWs=
|
||||
github.com/microsoft/dev-tunnels v0.0.25 h1:UlMKUI+2O8cSu4RlB52ioSyn1LthYSVkJA+CSTsdKoA=
|
||||
github.com/microsoft/dev-tunnels v0.0.25/go.mod h1:frU++12T/oqxckXkDpTuYa427ncguEOodSPZcGCCrzQ=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
|
||||
github.com/muesli/termenv v0.13.0 h1:wK20DRpJdDX8b7Ek2QfhvqhRQFZ237RGRO0RQ/Iqdy0=
|
||||
github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
|
||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
|
||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
|
||||
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/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0-rc5 h1:Ygwkfw9bpDvs+c9E34SdgGOj41dX/cbdlwvlWt0pnFI=
|
||||
github.com/opencontainers/image-spec v1.1.0-rc5/go.mod h1:X4pATf0uXsnn3g5aiGIsVnJBR4mxhKzfwmvK/B2NTm8=
|
||||
github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs=
|
||||
github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU=
|
||||
github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k=
|
||||
github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
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.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
|
||||
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rodaine/table v1.0.1 h1:U/VwCnUxlVYxw8+NJiLIuCxA/xa6jL38MY3FYysVWWQ=
|
||||
github.com/rodaine/table v1.0.1/go.mod h1:UVEtfBsflpeEcD56nF4F5AocNFta0ZuolpSVdPtlmP4=
|
||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
|
||||
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A=
|
||||
github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk=
|
||||
github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4=
|
||||
github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k=
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.8.0 h1:mr5An6X45Kb2nddcFlbmfHkLguCE9laoZCUzEEpIZXA=
|
||||
github.com/secure-systems-lab/go-securesystemslib v0.8.0/go.mod h1:UH2VZVuJfCYR8WgMlCU1uFsOUU+KeyrTWcSS73NBOzU=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
|
||||
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20230704064427-599ae7bbf278 h1:kdEGVAV4sO46DPtb8k793jiecUEhaX9ixoIBt41HEGU=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20230704064427-599ae7bbf278/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8=
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0=
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE=
|
||||
github.com/sourcegraph/jsonrpc2 v0.1.0 h1:ohJHjZ+PcaLxDUjqk2NC3tIGsVa5bXThe1ZheSXOjuk=
|
||||
github.com/sourcegraph/jsonrpc2 v0.1.0/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo=
|
||||
github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA=
|
||||
github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY=
|
||||
github.com/sigstore/protobuf-specs v0.3.2 h1:nCVARCN+fHjlNCk3ThNXwrZRqIommIeNKWwQvORuRQo=
|
||||
github.com/sigstore/protobuf-specs v0.3.2/go.mod h1:RZ0uOdJR4OB3tLQeAyWoJFbNCBFrPQdcokntde4zRBA=
|
||||
github.com/sigstore/rekor v1.3.6 h1:QvpMMJVWAp69a3CHzdrLelqEqpTM3ByQRt5B5Kspbi8=
|
||||
github.com/sigstore/rekor v1.3.6/go.mod h1:JDTSNNMdQ/PxdsS49DJkJ+pRJCO/83nbR5p3aZQteXc=
|
||||
github.com/sigstore/sigstore v1.8.3 h1:G7LVXqL+ekgYtYdksBks9B38dPoIsbscjQJX/MGWkA4=
|
||||
github.com/sigstore/sigstore v1.8.3/go.mod h1:mqbTEariiGA94cn6G3xnDiV6BD8eSLdL/eA7bvJ0fVs=
|
||||
github.com/sigstore/sigstore-go v0.3.0 h1:SxYqfonBrEhw8bNDelMieymxhdv7R9itiNZmtjOwKKU=
|
||||
github.com/sigstore/sigstore-go v0.3.0/go.mod h1:oJOH7UP8aTjAGnIVwq9sDif8M4CSCik84yN1vBuESbE=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.3 h1:LTfPadUAo+PDRUbbdqbeSl2OuoFQwUFTnJ4stu+nwWw=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/aws v1.8.3/go.mod h1:QV/Lxlxm0POyhfyBtIbTWxNeF18clMlkkyL9mu45y18=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.3 h1:xgbPRCr2npmmsuVVteJqi/ERw9+I13Wou7kq0Yk4D8g=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/azure v1.8.3/go.mod h1:G4+I83FILPX6MtnoaUdmv/bRGEVtR3JdLeJa/kXdk/0=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.3 h1:vDl2fqPT0h3D/k6NZPlqnKFd1tz3335wm39qjvpZNJc=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.8.3/go.mod h1:9uOJXbXEXj+M6QjMKH5PaL5WDMu43rHfbIMgXzA8eKI=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.3 h1:h9G8j+Ds21zqqulDbA/R/ft64oQQIyp8S7wJYABYSlg=
|
||||
github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.8.3/go.mod h1:zgCeHOuqF6k7A7TTEvftcA9V3FRzB7mrPtHOhXAQBnc=
|
||||
github.com/sigstore/timestamp-authority v1.2.2 h1:X4qyutnCQqJ0apMewFyx+3t7Tws00JQ/JonBiu3QvLE=
|
||||
github.com/sigstore/timestamp-authority v1.2.2/go.mod h1:nEah4Eq4wpliDjlY342rXclGSO7Kb9hoRrl9tqLW13A=
|
||||
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
|
||||
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
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/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI=
|
||||
github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug=
|
||||
github.com/theupdateframework/go-tuf/v2 v2.0.0-20240223092044-1e7978e83f63 h1:27XWhDZHPD+cufF6qSdYx6PgGQvD2jJ6pq9sDvR6VBk=
|
||||
github.com/theupdateframework/go-tuf/v2 v2.0.0-20240223092044-1e7978e83f63/go.mod h1:+gWwqe1pk4nvGeOKosGJqPgD+N/kbD9M0QVLL9TGIYU=
|
||||
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.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0=
|
||||
github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs=
|
||||
github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4=
|
||||
github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A=
|
||||
github.com/urfave/cli v1.22.12/go.mod h1:sSBEIC79qR6OvcmsD4U3KABeOTxDqQtdDnaFuUN30b8=
|
||||
github.com/vbatts/tar-split v0.11.3 h1:hLFqsOLQ1SsppQNTMpkpPXClLDfC2A3Zgy9OUU+RVck=
|
||||
github.com/vbatts/tar-split v0.11.3/go.mod h1:9QlHN18E+fEH7RdG+QAJJcuya3rqT7eXSTY7wGrAokY=
|
||||
github.com/yuin/goldmark v1.3.7/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
|
||||
github.com/yuin/goldmark v1.5.2/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.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=
|
||||
github.com/yuin/goldmark v1.5.4 h1:2uY/xC0roWy8IBEGLgB1ywIoEJFGmRrX21YQcvGZzjU=
|
||||
github.com/yuin/goldmark v1.5.4/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark-emoji v1.0.2 h1:c/RgTShNgHTtc6xdz2KKI74jJr6rWi7FPgnP9GAsO5s=
|
||||
github.com/yuin/goldmark-emoji v1.0.2/go.mod h1:RhP/RWpexdp+KHs7ghKnifRoIs/Bq4nDS7tRbCkOwKY=
|
||||
github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
|
||||
github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
|
||||
go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80=
|
||||
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
|
||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||
go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
|
||||
go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
|
||||
go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
|
||||
go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
|
||||
go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
|
||||
go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
|
||||
go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
|
||||
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
|
||||
go.step.sm/crypto v0.44.2 h1:t3p3uQ7raP2jp2ha9P6xkQF85TJZh+87xmjSLaib+jk=
|
||||
go.step.sm/crypto v0.44.2/go.mod h1:x1439EnFhadzhkuaGX7sz03LEMQ+jV4gRamf5LCZJQQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
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.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/oauth2 v0.4.0 h1:NF0gk8LVPg1Ml7SSbGyySuoxdsXitj7TvgvuRxIMc/M=
|
||||
golang.org/x/oauth2 v0.4.0/go.mod h1:RznEsdpjGAINPTOF0UH/t+xJ75L18YO3Ho6Pyn+uRec=
|
||||
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
|
||||
golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/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/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
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-20201119102817-f84b799fce68/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-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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/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.0.0-20220906165534-d0df966e6959/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.11.0 h1:F9tnn/DA/Im8nCwm+fX+1/eBwi4qFjRT++MhtVC4ZX0=
|
||||
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
|
||||
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
|
||||
golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
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.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
|
||||
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
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=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA=
|
||||
golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-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.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/api v0.172.0 h1:/1OcMZGPmW1rX2LCu2CmGUD1KXK1+pfzxotxyRUCCdk=
|
||||
google.golang.org/api v0.172.0/go.mod h1:+fJZq6QXWfa9pXhnIzsjx4yI22d4aI9ZpLb58gvXjis=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/genproto v0.0.0-20240311173647-c811ad7063a7 h1:ImUcDPHjTrAqNhlOkSocDLfG9rrNHH7w7uoKWPaWZ8s=
|
||||
google.golang.org/genproto v0.0.0-20240311173647-c811ad7063a7/go.mod h1:/3XmxOjePkvmKrHuBy4zNFw7IzxJXtAgdpXi8Ll990U=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240311173647-c811ad7063a7 h1:oqta3O3AnlWbmIE3bFnWbu4bRxZjfbWCp0cKSuZh01E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20240311173647-c811ad7063a7/go.mod h1:VQW3tUculP/D4B+xVCo+VgSq8As6wA9ZjHl//pmk+6s=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 h1:NnYq6UN9ReLM9/Y01KWNOWyI5xQ9kbIms5GGJVwS/Yc=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
|
||||
google.golang.org/grpc v1.62.2 h1:iEIj1U5qjyBjzkM5nk3Fq+S1IbjbXSyqeULZ1Nfo4AA=
|
||||
google.golang.org/grpc v1.62.2/go.mod h1:IWTG0VlJLCh1SkC58F7np9ka9mx/WNkjl4PGJaiq+QE=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/go-jose/go-jose.v2 v2.6.3 h1:nt80fvSDlhKWQgSWyHyy5CfmlQr+asih51R8PTWNKKs=
|
||||
gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI=
|
||||
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
|
||||
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
|
||||
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
|
||||
k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw=
|
||||
k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ type cfg struct {
|
|||
token string
|
||||
}
|
||||
|
||||
func (c cfg) Token(hostname string) (string, string) {
|
||||
func (c cfg) ActiveToken(hostname string) (string, string) {
|
||||
return c.token, "oauth_token"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ func init() {
|
|||
|
||||
// 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.
|
||||
// content might cause misalignment in tcell-enabled views.
|
||||
//
|
||||
// https://github.com/gdamore/tcell/commit/2f889d79bd61b1fd2f43372529975a65b792a7ae
|
||||
_ = os.Setenv("TCELL_MINIMIZE", "1")
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ type Codespace struct {
|
|||
GitStatus CodespaceGitStatus `json:"git_status"`
|
||||
Connection CodespaceConnection `json:"connection"`
|
||||
Machine CodespaceMachine `json:"machine"`
|
||||
RuntimeConstraints RuntimeConstraints `json:"runtime_constraints"`
|
||||
VSCSTarget string `json:"vscs_target"`
|
||||
PendingOperation bool `json:"pending_operation"`
|
||||
PendingOperationDisabledReason string `json:"pending_operation_disabled_reason"`
|
||||
|
|
@ -246,11 +247,20 @@ const (
|
|||
)
|
||||
|
||||
type CodespaceConnection struct {
|
||||
SessionID string `json:"sessionId"`
|
||||
SessionToken string `json:"sessionToken"`
|
||||
RelayEndpoint string `json:"relayEndpoint"`
|
||||
RelaySAS string `json:"relaySas"`
|
||||
HostPublicKeys []string `json:"hostPublicKeys"`
|
||||
TunnelProperties TunnelProperties `json:"tunnelProperties"`
|
||||
}
|
||||
|
||||
type TunnelProperties struct {
|
||||
ConnectAccessToken string `json:"connectAccessToken"`
|
||||
ManagePortsAccessToken string `json:"managePortsAccessToken"`
|
||||
ServiceUri string `json:"serviceUri"`
|
||||
TunnelId string `json:"tunnelId"`
|
||||
ClusterId string `json:"clusterId"`
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
|
||||
type RuntimeConstraints struct {
|
||||
AllowedPortPrivacySettings []string `json:"allowed_port_privacy_settings"`
|
||||
}
|
||||
|
||||
// ListCodespaceFields is the list of exportable fields for a codespace when using the `gh cs list` command.
|
||||
|
|
@ -629,6 +639,45 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc
|
|||
return response.Machines, nil
|
||||
}
|
||||
|
||||
// GetCodespacesPermissionsCheck returns a bool indicating whether the user has accepted permissions for the given repo and devcontainer path.
|
||||
func (a *API) GetCodespacesPermissionsCheck(ctx context.Context, repoID int, branch string, devcontainerPath string) (bool, error) {
|
||||
reqURL := fmt.Sprintf("%s/repositories/%d/codespaces/permissions_check", a.githubAPI, repoID)
|
||||
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error creating request: %w", err)
|
||||
}
|
||||
|
||||
q := req.URL.Query()
|
||||
q.Add("ref", branch)
|
||||
q.Add("devcontainer_path", devcontainerPath)
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
a.setHeaders(req)
|
||||
resp, err := a.do(ctx, req, "/repositories/*/codespaces/permissions_check")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error making request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, api.HandleHTTPError(resp)
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error reading response body: %w", err)
|
||||
}
|
||||
|
||||
var response struct {
|
||||
Accepted bool `json:"accepted"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &response); err != nil {
|
||||
return false, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
return response.Accepted, nil
|
||||
}
|
||||
|
||||
// RepoSearchParameters are the optional parameters for searching for repositories.
|
||||
type RepoSearchParameters struct {
|
||||
// The maximum number of repos to return. At most 100 repos are returned even if this value is greater than 100.
|
||||
|
|
@ -1162,3 +1211,13 @@ func (a *API) withRetry(f func() (*http.Response, error)) (*http.Response, error
|
|||
return nil, fmt.Errorf("received response with status code %d", resp.StatusCode)
|
||||
}, backoff.WithMaxRetries(bo, 3))
|
||||
}
|
||||
|
||||
// HTTPClient returns the HTTP client used to make requests to the API.
|
||||
func (a *API) HTTPClient() (*http.Client, error) {
|
||||
httpClient, err := a.client()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return httpClient, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
ghmock "github.com/cli/cli/v2/internal/gh/mock"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
)
|
||||
|
||||
|
|
@ -137,13 +139,13 @@ func createHttpClient() (*http.Client, error) {
|
|||
func TestNew_APIURL_dotcomConfig(t *testing.T) {
|
||||
t.Setenv("GITHUB_API_URL", "")
|
||||
t.Setenv("GITHUB_SERVER_URL", "https://github.com")
|
||||
cfg := &config.ConfigMock{
|
||||
AuthenticationFunc: func() *config.AuthConfig {
|
||||
cfg := &ghmock.ConfigMock{
|
||||
AuthenticationFunc: func() gh.AuthConfig {
|
||||
return &config.AuthConfig{}
|
||||
},
|
||||
}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return cfg, nil
|
||||
},
|
||||
}
|
||||
|
|
@ -160,15 +162,15 @@ func TestNew_APIURL_dotcomConfig(t *testing.T) {
|
|||
func TestNew_APIURL_customConfig(t *testing.T) {
|
||||
t.Setenv("GITHUB_API_URL", "")
|
||||
t.Setenv("GITHUB_SERVER_URL", "https://github.mycompany.com")
|
||||
cfg := &config.ConfigMock{
|
||||
AuthenticationFunc: func() *config.AuthConfig {
|
||||
cfg := &ghmock.ConfigMock{
|
||||
AuthenticationFunc: func() gh.AuthConfig {
|
||||
authCfg := &config.AuthConfig{}
|
||||
authCfg.SetDefaultHost("github.mycompany.com", "GH_HOST")
|
||||
return authCfg
|
||||
},
|
||||
}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return cfg, nil
|
||||
},
|
||||
}
|
||||
|
|
@ -185,13 +187,13 @@ func TestNew_APIURL_customConfig(t *testing.T) {
|
|||
func TestNew_APIURL_env(t *testing.T) {
|
||||
t.Setenv("GITHUB_API_URL", "https://api.mycompany.com")
|
||||
t.Setenv("GITHUB_SERVER_URL", "https://mycompany.com")
|
||||
cfg := &config.ConfigMock{
|
||||
AuthenticationFunc: func() *config.AuthConfig {
|
||||
cfg := &ghmock.ConfigMock{
|
||||
AuthenticationFunc: func() gh.AuthConfig {
|
||||
return &config.AuthConfig{}
|
||||
},
|
||||
}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return cfg, nil
|
||||
},
|
||||
}
|
||||
|
|
@ -208,7 +210,7 @@ func TestNew_APIURL_env(t *testing.T) {
|
|||
func TestNew_APIURL_dotcomFallback(t *testing.T) {
|
||||
t.Setenv("GITHUB_API_URL", "")
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return nil, errors.New("Failed to load")
|
||||
},
|
||||
}
|
||||
|
|
@ -222,13 +224,13 @@ func TestNew_APIURL_dotcomFallback(t *testing.T) {
|
|||
func TestNew_ServerURL_dotcomConfig(t *testing.T) {
|
||||
t.Setenv("GITHUB_SERVER_URL", "")
|
||||
t.Setenv("GITHUB_API_URL", "https://api.github.com")
|
||||
cfg := &config.ConfigMock{
|
||||
AuthenticationFunc: func() *config.AuthConfig {
|
||||
cfg := &ghmock.ConfigMock{
|
||||
AuthenticationFunc: func() gh.AuthConfig {
|
||||
return &config.AuthConfig{}
|
||||
},
|
||||
}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return cfg, nil
|
||||
},
|
||||
}
|
||||
|
|
@ -245,15 +247,15 @@ func TestNew_ServerURL_dotcomConfig(t *testing.T) {
|
|||
func TestNew_ServerURL_customConfig(t *testing.T) {
|
||||
t.Setenv("GITHUB_SERVER_URL", "")
|
||||
t.Setenv("GITHUB_API_URL", "https://github.mycompany.com/api/v3")
|
||||
cfg := &config.ConfigMock{
|
||||
AuthenticationFunc: func() *config.AuthConfig {
|
||||
cfg := &ghmock.ConfigMock{
|
||||
AuthenticationFunc: func() gh.AuthConfig {
|
||||
authCfg := &config.AuthConfig{}
|
||||
authCfg.SetDefaultHost("github.mycompany.com", "GH_HOST")
|
||||
return authCfg
|
||||
},
|
||||
}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return cfg, nil
|
||||
},
|
||||
}
|
||||
|
|
@ -270,13 +272,13 @@ func TestNew_ServerURL_customConfig(t *testing.T) {
|
|||
func TestNew_ServerURL_env(t *testing.T) {
|
||||
t.Setenv("GITHUB_SERVER_URL", "https://mycompany.com")
|
||||
t.Setenv("GITHUB_API_URL", "https://api.mycompany.com")
|
||||
cfg := &config.ConfigMock{
|
||||
AuthenticationFunc: func() *config.AuthConfig {
|
||||
cfg := &ghmock.ConfigMock{
|
||||
AuthenticationFunc: func() gh.AuthConfig {
|
||||
return &config.AuthConfig{}
|
||||
},
|
||||
}
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return cfg, nil
|
||||
},
|
||||
}
|
||||
|
|
@ -293,7 +295,7 @@ func TestNew_ServerURL_env(t *testing.T) {
|
|||
func TestNew_ServerURL_dotcomFallback(t *testing.T) {
|
||||
t.Setenv("GITHUB_SERVER_URL", "")
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return nil, errors.New("Failed to load")
|
||||
},
|
||||
}
|
||||
|
|
@ -565,7 +567,7 @@ func TestRetries(t *testing.T) {
|
|||
t.Fatalf("expected codespace name to be %q but got %q", csName, cs.Name)
|
||||
}
|
||||
callCount = 0
|
||||
handler = func(w http.ResponseWriter, r *http.Request) {
|
||||
handler = func(w http.ResponseWriter, _ *http.Request) {
|
||||
callCount++
|
||||
err := json.NewEncoder(w).Encode(Codespace{
|
||||
Name: csName,
|
||||
|
|
@ -755,7 +757,7 @@ func TestAPI_EditCodespace(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func createFakeEditPendingOpServer(t *testing.T) *httptest.Server {
|
||||
func createFakeEditPendingOpServer() *httptest.Server {
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPatch {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
|
|
@ -776,7 +778,7 @@ func createFakeEditPendingOpServer(t *testing.T) *httptest.Server {
|
|||
}
|
||||
|
||||
func TestAPI_EditCodespacePendingOperation(t *testing.T) {
|
||||
svr := createFakeEditPendingOpServer(t)
|
||||
svr := createFakeEditPendingOpServer()
|
||||
defer svr.Close()
|
||||
|
||||
a := &API{
|
||||
|
|
|
|||
|
|
@ -5,24 +5,32 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/cli/cli/v2/internal/codespaces/api"
|
||||
"github.com/cli/cli/v2/pkg/liveshare"
|
||||
"github.com/cli/cli/v2/internal/codespaces/connection"
|
||||
)
|
||||
|
||||
func connectionReady(codespace *api.Codespace) bool {
|
||||
return codespace.Connection.SessionID != "" &&
|
||||
codespace.Connection.SessionToken != "" &&
|
||||
codespace.Connection.RelayEndpoint != "" &&
|
||||
codespace.Connection.RelaySAS != "" &&
|
||||
codespace.State == api.CodespaceStateAvailable
|
||||
// If the codespace is not available, it is not ready
|
||||
if codespace.State != api.CodespaceStateAvailable {
|
||||
return false
|
||||
}
|
||||
|
||||
return codespace.Connection.TunnelProperties.ConnectAccessToken != "" &&
|
||||
codespace.Connection.TunnelProperties.ManagePortsAccessToken != "" &&
|
||||
codespace.Connection.TunnelProperties.ServiceUri != "" &&
|
||||
codespace.Connection.TunnelProperties.TunnelId != "" &&
|
||||
codespace.Connection.TunnelProperties.ClusterId != "" &&
|
||||
codespace.Connection.TunnelProperties.Domain != ""
|
||||
}
|
||||
|
||||
type apiClient interface {
|
||||
GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error)
|
||||
StartCodespace(ctx context.Context, name string) error
|
||||
HTTPClient() (*http.Client, error)
|
||||
}
|
||||
|
||||
type progressIndicator interface {
|
||||
|
|
@ -30,11 +38,6 @@ type progressIndicator interface {
|
|||
StopProgressIndicator()
|
||||
}
|
||||
|
||||
type logger interface {
|
||||
Println(v ...interface{})
|
||||
Printf(f string, v ...interface{})
|
||||
}
|
||||
|
||||
type TimeoutError struct {
|
||||
message string
|
||||
}
|
||||
|
|
@ -43,9 +46,27 @@ func (e *TimeoutError) Error() string {
|
|||
return e.message
|
||||
}
|
||||
|
||||
// ConnectToLiveshare waits for a Codespace to become running,
|
||||
// and connects to it using a Live Share session.
|
||||
func ConnectToLiveshare(ctx context.Context, progress progressIndicator, sessionLogger logger, apiClient apiClient, codespace *api.Codespace) (*liveshare.Session, error) {
|
||||
// GetCodespaceConnection waits until a codespace is able
|
||||
// to be connected to and initializes a connection to it.
|
||||
func GetCodespaceConnection(ctx context.Context, progress progressIndicator, apiClient apiClient, codespace *api.Codespace) (*connection.CodespaceConnection, error) {
|
||||
codespace, err := waitUntilCodespaceConnectionReady(ctx, progress, apiClient, codespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
progress.StartProgressIndicatorWithLabel("Connecting to codespace")
|
||||
defer progress.StopProgressIndicator()
|
||||
|
||||
httpClient, err := apiClient.HTTPClient()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting http client: %w", err)
|
||||
}
|
||||
|
||||
return connection.NewCodespaceConnection(ctx, codespace, httpClient)
|
||||
}
|
||||
|
||||
// waitUntilCodespaceConnectionReady waits for a Codespace to be running and is able to be connected to.
|
||||
func waitUntilCodespaceConnectionReady(ctx context.Context, progress progressIndicator, apiClient apiClient, codespace *api.Codespace) (*api.Codespace, error) {
|
||||
if codespace.State != api.CodespaceStateAvailable {
|
||||
progress.StartProgressIndicatorWithLabel("Starting codespace")
|
||||
defer progress.StopProgressIndicator()
|
||||
|
|
@ -83,17 +104,7 @@ func ConnectToLiveshare(ctx context.Context, progress progressIndicator, session
|
|||
}
|
||||
}
|
||||
|
||||
progress.StartProgressIndicatorWithLabel("Connecting to codespace")
|
||||
defer progress.StopProgressIndicator()
|
||||
|
||||
return liveshare.Connect(ctx, liveshare.Options{
|
||||
SessionID: codespace.Connection.SessionID,
|
||||
SessionToken: codespace.Connection.SessionToken,
|
||||
RelaySAS: codespace.Connection.RelaySAS,
|
||||
RelayEndpoint: codespace.Connection.RelayEndpoint,
|
||||
HostPublicKeys: codespace.Connection.HostPublicKeys,
|
||||
Logger: sessionLogger,
|
||||
})
|
||||
return codespace, nil
|
||||
}
|
||||
|
||||
// ListenTCP starts a localhost tcp listener on 127.0.0.1 (unless allInterfaces is true) and returns the listener and bound port
|
||||
|
|
|
|||
168
internal/codespaces/connection/connection.go
Normal file
168
internal/codespaces/connection/connection.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package connection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/cli/cli/v2/internal/codespaces/api"
|
||||
"github.com/microsoft/dev-tunnels/go/tunnels"
|
||||
)
|
||||
|
||||
const (
|
||||
clientName = "gh"
|
||||
)
|
||||
|
||||
type TunnelClient struct {
|
||||
*tunnels.Client
|
||||
connected bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type CodespaceConnection struct {
|
||||
tunnelProperties api.TunnelProperties
|
||||
TunnelManager *tunnels.Manager
|
||||
TunnelClient *TunnelClient
|
||||
Options *tunnels.TunnelRequestOptions
|
||||
Tunnel *tunnels.Tunnel
|
||||
AllowedPortPrivacySettings []string
|
||||
}
|
||||
|
||||
// NewCodespaceConnection initializes a connection to a codespace.
|
||||
// This connections allows for port forwarding which enables the
|
||||
// use of most features of the codespace command.
|
||||
func NewCodespaceConnection(ctx context.Context, codespace *api.Codespace, httpClient *http.Client) (connection *CodespaceConnection, err error) {
|
||||
// Get the tunnel properties
|
||||
tunnelProperties := codespace.Connection.TunnelProperties
|
||||
|
||||
// Create the tunnel manager
|
||||
tunnelManager, err := getTunnelManager(tunnelProperties, httpClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting tunnel management client: %w", err)
|
||||
}
|
||||
|
||||
// Calculate allowed port privacy settings
|
||||
allowedPortPrivacySettings := codespace.RuntimeConstraints.AllowedPortPrivacySettings
|
||||
|
||||
// Get the access tokens
|
||||
connectToken := tunnelProperties.ConnectAccessToken
|
||||
managementToken := tunnelProperties.ManagePortsAccessToken
|
||||
|
||||
// Create the tunnel definition
|
||||
tunnel := &tunnels.Tunnel{
|
||||
AccessTokens: map[tunnels.TunnelAccessScope]string{tunnels.TunnelAccessScopeConnect: connectToken, tunnels.TunnelAccessScopeManagePorts: managementToken},
|
||||
TunnelID: tunnelProperties.TunnelId,
|
||||
ClusterID: tunnelProperties.ClusterId,
|
||||
Domain: tunnelProperties.Domain,
|
||||
}
|
||||
|
||||
// Create options
|
||||
options := &tunnels.TunnelRequestOptions{
|
||||
IncludePorts: true,
|
||||
}
|
||||
|
||||
// Create the tunnel client (not connected yet)
|
||||
tunnelClient, err := getTunnelClient(ctx, tunnelManager, tunnel, options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting tunnel client: %w", err)
|
||||
}
|
||||
|
||||
return &CodespaceConnection{
|
||||
tunnelProperties: tunnelProperties,
|
||||
TunnelManager: tunnelManager,
|
||||
TunnelClient: tunnelClient,
|
||||
Options: options,
|
||||
Tunnel: tunnel,
|
||||
AllowedPortPrivacySettings: allowedPortPrivacySettings,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Connect connects the client to the tunnel.
|
||||
func (c *CodespaceConnection) Connect(ctx context.Context) error {
|
||||
// Lock the mutex to prevent race conditions with the underlying SSH connection
|
||||
c.TunnelClient.mu.Lock()
|
||||
defer c.TunnelClient.mu.Unlock()
|
||||
|
||||
// If already connected, return
|
||||
if c.TunnelClient.connected {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Connect to the tunnel
|
||||
if err := c.TunnelClient.Client.Connect(ctx, ""); err != nil {
|
||||
return fmt.Errorf("error connecting to tunnel: %w", err)
|
||||
}
|
||||
|
||||
// Set the connected flag so we know we're connected
|
||||
c.TunnelClient.connected = true
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the underlying tunnel client SSH connection.
|
||||
func (c *CodespaceConnection) Close() error {
|
||||
// Lock the mutex to prevent race conditions with the underlying SSH connection
|
||||
c.TunnelClient.mu.Lock()
|
||||
defer c.TunnelClient.mu.Unlock()
|
||||
|
||||
// Don't close if we're not connected
|
||||
if c.TunnelClient != nil && c.TunnelClient.connected {
|
||||
if err := c.TunnelClient.Close(); err != nil {
|
||||
return fmt.Errorf("failed to close tunnel client connection: %w", err)
|
||||
}
|
||||
|
||||
c.TunnelClient.connected = false
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getTunnelManager creates a tunnel manager for the given codespace.
|
||||
// The tunnel manager is used to get the tunnel hosted in the codespace that we
|
||||
// want to connect to and perform operations on ports (add, remove, list, etc.).
|
||||
func getTunnelManager(tunnelProperties api.TunnelProperties, httpClient *http.Client) (tunnelManager *tunnels.Manager, err error) {
|
||||
userAgent := []tunnels.UserAgent{{Name: clientName}}
|
||||
url, err := url.Parse(tunnelProperties.ServiceUri)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing tunnel service uri: %w", err)
|
||||
}
|
||||
|
||||
// Create the tunnel manager
|
||||
tunnelManager, err = tunnels.NewManager(userAgent, nil, url, httpClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating tunnel manager: %w", err)
|
||||
}
|
||||
|
||||
return tunnelManager, nil
|
||||
}
|
||||
|
||||
// getTunnelClient creates a tunnel client for the given tunnel.
|
||||
// The tunnel client is used to connect to the tunnel and allows
|
||||
// for ports to be forwarded locally.
|
||||
func getTunnelClient(ctx context.Context, tunnelManager *tunnels.Manager, tunnel *tunnels.Tunnel, options *tunnels.TunnelRequestOptions) (tunnelClient *TunnelClient, err error) {
|
||||
// Get the tunnel that we want to connect to
|
||||
codespaceTunnel, err := tunnelManager.GetTunnel(ctx, tunnel, options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting tunnel: %w", err)
|
||||
}
|
||||
|
||||
// Copy the access tokens from the tunnel definition
|
||||
codespaceTunnel.AccessTokens = tunnel.AccessTokens
|
||||
|
||||
// We need to pass false for accept local connections because we don't want to automatically connect to all forwarded ports
|
||||
client, err := tunnels.NewClient(log.New(io.Discard, "", log.LstdFlags), codespaceTunnel, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating tunnel client: %w", err)
|
||||
}
|
||||
|
||||
tunnelClient = &TunnelClient{
|
||||
Client: client,
|
||||
connected: false,
|
||||
}
|
||||
|
||||
return tunnelClient, nil
|
||||
}
|
||||
81
internal/codespaces/connection/connection_test.go
Normal file
81
internal/codespaces/connection/connection_test.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package connection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/codespaces/api"
|
||||
"github.com/microsoft/dev-tunnels/go/tunnels"
|
||||
)
|
||||
|
||||
func TestNewCodespaceConnection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a mock codespace
|
||||
connection := api.CodespaceConnection{
|
||||
TunnelProperties: api.TunnelProperties{
|
||||
ConnectAccessToken: "connect-token",
|
||||
ManagePortsAccessToken: "manage-ports-token",
|
||||
ServiceUri: "http://global.rel.tunnels.api.visualstudio.com/",
|
||||
TunnelId: "tunnel-id",
|
||||
ClusterId: "usw2",
|
||||
Domain: "domain.com",
|
||||
},
|
||||
}
|
||||
allowedPortPrivacySettings := []string{"public", "private"}
|
||||
codespace := &api.Codespace{
|
||||
Connection: connection,
|
||||
RuntimeConstraints: api.RuntimeConstraints{AllowedPortPrivacySettings: allowedPortPrivacySettings},
|
||||
}
|
||||
|
||||
// Create the mock HTTP client
|
||||
httpClient, err := NewMockHttpClient()
|
||||
if err != nil {
|
||||
t.Fatalf("NewHttpClient returned an error: %v", err)
|
||||
}
|
||||
|
||||
// Create the connection
|
||||
conn, err := NewCodespaceConnection(ctx, codespace, httpClient)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodespaceConnection returned an error: %v", err)
|
||||
}
|
||||
|
||||
// Verify closing before connected doesn't throw
|
||||
err = conn.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("Close returned an error: %v", err)
|
||||
}
|
||||
|
||||
// Check that the connection was created successfully
|
||||
if conn == nil {
|
||||
t.Fatal("NewCodespaceConnection returned nil")
|
||||
}
|
||||
|
||||
// Verify that the connection contains the expected tunnel properties
|
||||
if conn.tunnelProperties != connection.TunnelProperties {
|
||||
t.Fatalf("NewCodespaceConnection returned a connection with unexpected tunnel properties: %+v", conn.tunnelProperties)
|
||||
}
|
||||
|
||||
// Verify that the connection contains the expected tunnel
|
||||
expectedTunnel := &tunnels.Tunnel{
|
||||
AccessTokens: map[tunnels.TunnelAccessScope]string{tunnels.TunnelAccessScopeConnect: connection.TunnelProperties.ConnectAccessToken, tunnels.TunnelAccessScopeManagePorts: connection.TunnelProperties.ManagePortsAccessToken},
|
||||
TunnelID: connection.TunnelProperties.TunnelId,
|
||||
ClusterID: connection.TunnelProperties.ClusterId,
|
||||
Domain: connection.TunnelProperties.Domain,
|
||||
}
|
||||
if !reflect.DeepEqual(conn.Tunnel, expectedTunnel) {
|
||||
t.Fatalf("NewCodespaceConnection returned a connection with unexpected tunnel: %+v", conn.Tunnel)
|
||||
}
|
||||
|
||||
// Verify that the connection contains the expected tunnel options
|
||||
expectedOptions := &tunnels.TunnelRequestOptions{IncludePorts: true}
|
||||
if !reflect.DeepEqual(conn.Options, expectedOptions) {
|
||||
t.Fatalf("NewCodespaceConnection returned a connection with unexpected options: %+v", conn.Options)
|
||||
}
|
||||
|
||||
// Verify that the connection contains the expected allowed port privacy settings
|
||||
if !reflect.DeepEqual(conn.AllowedPortPrivacySettings, allowedPortPrivacySettings) {
|
||||
t.Fatalf("NewCodespaceConnection returned a connection with unexpected allowed port privacy settings: %+v", conn.AllowedPortPrivacySettings)
|
||||
}
|
||||
}
|
||||
396
internal/codespaces/connection/tunnels_api_server_mock.go
Normal file
396
internal/codespaces/connection/tunnels_api_server_mock.go
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
package connection
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/microsoft/dev-tunnels/go/tunnels"
|
||||
tunnelssh "github.com/microsoft/dev-tunnels/go/tunnels/ssh"
|
||||
"github.com/microsoft/dev-tunnels/go/tunnels/ssh/messages"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func NewMockHttpClient() (*http.Client, error) {
|
||||
accessToken := "tunnel access-token"
|
||||
relayServer, err := newMockrelayServer(withAccessToken(accessToken))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("NewrelayServer returned an error: %w", err)
|
||||
}
|
||||
|
||||
hostURL := strings.Replace(relayServer.URL(), "http://", "ws://", 1)
|
||||
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var response []byte
|
||||
if r.URL.Path == "/api/v1/tunnels/tunnel-id" {
|
||||
tunnel := &tunnels.Tunnel{
|
||||
AccessTokens: map[tunnels.TunnelAccessScope]string{
|
||||
tunnels.TunnelAccessScopeConnect: accessToken,
|
||||
},
|
||||
Endpoints: []tunnels.TunnelEndpoint{
|
||||
{
|
||||
HostID: "host1",
|
||||
TunnelRelayTunnelEndpoint: tunnels.TunnelRelayTunnelEndpoint{
|
||||
ClientRelayURI: hostURL,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
response, err = json.Marshal(*tunnel)
|
||||
if err != nil {
|
||||
log.Fatalf("json.Marshal returned an error: %v", err)
|
||||
}
|
||||
} else if strings.HasPrefix(r.URL.Path, "/api/v1/tunnels/tunnel-id/ports") {
|
||||
// Use regex to check if the path ends with a number
|
||||
match, err := regexp.MatchString(`\/\d+$`, r.URL.Path)
|
||||
if err != nil {
|
||||
log.Fatalf("regexp.MatchString returned an error: %v", err)
|
||||
}
|
||||
|
||||
// If the path ends with a number, it's a request for a specific port
|
||||
if match || r.Method == http.MethodPost {
|
||||
if r.Method == http.MethodDelete {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
tunnelPort := &tunnels.TunnelPort{
|
||||
AccessControl: &tunnels.TunnelAccessControl{
|
||||
Entries: []tunnels.TunnelAccessControlEntry{},
|
||||
},
|
||||
}
|
||||
|
||||
// Convert the tunnel to JSON and write it to the response
|
||||
response, err = json.Marshal(*tunnelPort)
|
||||
if err != nil {
|
||||
log.Fatalf("json.Marshal returned an error: %v", err)
|
||||
}
|
||||
} else {
|
||||
// If the path doesn't end with a number and we aren't making a POST request, return an array of ports
|
||||
tunnelPorts := []tunnels.TunnelPort{
|
||||
{
|
||||
AccessControl: &tunnels.TunnelAccessControl{
|
||||
Entries: []tunnels.TunnelAccessControlEntry{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
response, err = json.Marshal(tunnelPorts)
|
||||
if err != nil {
|
||||
log.Fatalf("json.Marshal returned an error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Write the response
|
||||
_, _ = w.Write(response)
|
||||
}))
|
||||
|
||||
url, err := url.Parse(mockServer.URL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("url.Parse returned an error: %w", err)
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(url),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type relayServer struct {
|
||||
httpServer *httptest.Server
|
||||
errc chan error
|
||||
sshConfig *ssh.ServerConfig
|
||||
channels map[string]channelHandler
|
||||
accessToken string
|
||||
|
||||
serverConn *ssh.ServerConn
|
||||
}
|
||||
|
||||
type relayServerOption func(*relayServer)
|
||||
type channelHandler func(context.Context, ssh.NewChannel) error
|
||||
|
||||
func newMockrelayServer(opts ...relayServerOption) (*relayServer, error) {
|
||||
server := &relayServer{
|
||||
errc: make(chan error),
|
||||
sshConfig: &ssh.ServerConfig{
|
||||
NoClientAuth: true,
|
||||
},
|
||||
}
|
||||
|
||||
// Create a private key with the crypto package
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key: %w", err)
|
||||
}
|
||||
|
||||
privateKeyPEM := pem.EncodeToMemory(
|
||||
&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
},
|
||||
)
|
||||
|
||||
// Parse the private key
|
||||
sshPrivateKey, err := ssh.ParsePrivateKey(privateKeyPEM)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
|
||||
server.sshConfig.AddHostKey(ssh.Signer(sshPrivateKey))
|
||||
|
||||
server.httpServer = httptest.NewServer(http.HandlerFunc(makeConnection(server)))
|
||||
|
||||
for _, opt := range opts {
|
||||
opt(server)
|
||||
}
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
func withAccessToken(accessToken string) func(*relayServer) {
|
||||
return func(server *relayServer) {
|
||||
server.accessToken = accessToken
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *relayServer) URL() string {
|
||||
return rs.httpServer.URL
|
||||
}
|
||||
|
||||
func (rs *relayServer) Err() <-chan error {
|
||||
return rs.errc
|
||||
}
|
||||
|
||||
func (rs *relayServer) sendError(err error) {
|
||||
select {
|
||||
case rs.errc <- err:
|
||||
default:
|
||||
// channel is blocked with a previous error, so we ignore this one
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *relayServer) ForwardPort(ctx context.Context, port uint16) error {
|
||||
pfr := messages.NewPortForwardRequest("127.0.0.1", uint32(port))
|
||||
b, err := pfr.Marshal()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error marshaling port forward request: %w", err)
|
||||
}
|
||||
|
||||
replied, data, err := rs.serverConn.SendRequest(messages.PortForwardRequestType, true, b)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error sending port forward request: %w", err)
|
||||
}
|
||||
|
||||
if !replied {
|
||||
return fmt.Errorf("port forward request not replied")
|
||||
}
|
||||
|
||||
if data == nil {
|
||||
return fmt.Errorf("no data returned")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func makeConnection(server *relayServer) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if server.accessToken != "" {
|
||||
if r.Header.Get("Authorization") != server.accessToken {
|
||||
server.sendError(fmt.Errorf("invalid access token"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
upgrader := websocket.Upgrader{}
|
||||
c, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
server.sendError(fmt.Errorf("error upgrading to websocket: %w", err))
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := c.Close(); err != nil {
|
||||
server.sendError(fmt.Errorf("error closing websocket: %w", err))
|
||||
}
|
||||
}()
|
||||
|
||||
socketConn := newSocketConn(c)
|
||||
serverConn, chans, reqs, err := ssh.NewServerConn(socketConn, server.sshConfig)
|
||||
if err != nil {
|
||||
server.sendError(fmt.Errorf("error creating ssh server conn: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
go handleRequests(ctx, convertRequests(reqs))
|
||||
|
||||
server.serverConn = serverConn
|
||||
if err := handleChannels(ctx, server, chans); err != nil {
|
||||
server.sendError(fmt.Errorf("error handling channels: %w", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (sr *sshRequest) Type() string {
|
||||
return sr.request.Type
|
||||
}
|
||||
|
||||
type sshRequest struct {
|
||||
request *ssh.Request
|
||||
}
|
||||
|
||||
// Reply method for sshRequest to satisfy the tunnelssh.SSHRequest interface
|
||||
func (sr *sshRequest) Reply(success bool, message []byte) error {
|
||||
return sr.request.Reply(success, message)
|
||||
}
|
||||
|
||||
// convertRequests function
|
||||
func convertRequests(reqs <-chan *ssh.Request) <-chan tunnelssh.SSHRequest {
|
||||
out := make(chan tunnelssh.SSHRequest)
|
||||
go func() {
|
||||
for req := range reqs {
|
||||
out <- &sshRequest{req}
|
||||
}
|
||||
close(out)
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func handleChannels(ctx context.Context, server *relayServer, chans <-chan ssh.NewChannel) error {
|
||||
errc := make(chan error, 1)
|
||||
go func() {
|
||||
for ch := range chans {
|
||||
if handler, ok := server.channels[ch.ChannelType()]; ok {
|
||||
if err := handler(ctx, ch); err != nil {
|
||||
errc <- err
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// generic accept of the channel to not block
|
||||
_, _, err := ch.Accept()
|
||||
if err != nil {
|
||||
errc <- fmt.Errorf("error accepting channel: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return awaitError(ctx, errc)
|
||||
}
|
||||
|
||||
func handleRequests(ctx context.Context, reqs <-chan tunnelssh.SSHRequest) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case req, ok := <-reqs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Type() == "RefreshPorts" {
|
||||
_ = req.Reply(true, nil)
|
||||
continue
|
||||
} else {
|
||||
_ = req.Reply(false, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func awaitError(ctx context.Context, errc <-chan error) error {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case err := <-errc:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
type socketConn struct {
|
||||
*websocket.Conn
|
||||
|
||||
reader io.Reader
|
||||
writeMutex sync.Mutex
|
||||
readMutex sync.Mutex
|
||||
}
|
||||
|
||||
func newSocketConn(conn *websocket.Conn) *socketConn {
|
||||
return &socketConn{Conn: conn}
|
||||
}
|
||||
|
||||
func (s *socketConn) Read(b []byte) (int, error) {
|
||||
s.readMutex.Lock()
|
||||
defer s.readMutex.Unlock()
|
||||
|
||||
if s.reader == nil {
|
||||
msgType, r, err := s.Conn.NextReader()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error getting next reader: %w", err)
|
||||
}
|
||||
if msgType != websocket.BinaryMessage {
|
||||
return 0, fmt.Errorf("invalid message type")
|
||||
}
|
||||
s.reader = r
|
||||
}
|
||||
|
||||
bytesRead, err := s.reader.Read(b)
|
||||
if err != nil {
|
||||
s.reader = nil
|
||||
|
||||
if err == io.EOF {
|
||||
err = nil
|
||||
}
|
||||
}
|
||||
|
||||
return bytesRead, err
|
||||
}
|
||||
|
||||
func (s *socketConn) Write(b []byte) (int, error) {
|
||||
s.writeMutex.Lock()
|
||||
defer s.writeMutex.Unlock()
|
||||
|
||||
w, err := s.Conn.NextWriter(websocket.BinaryMessage)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error getting next writer: %w", err)
|
||||
}
|
||||
|
||||
n, err := w.Write(b)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error writing: %w", err)
|
||||
}
|
||||
|
||||
if err := w.Close(); err != nil {
|
||||
return 0, fmt.Errorf("error closing writer: %w", err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *socketConn) SetDeadline(deadline time.Time) error {
|
||||
if err := s.Conn.SetReadDeadline(deadline); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Conn.SetWriteDeadline(deadline)
|
||||
}
|
||||
414
internal/codespaces/portforwarder/port_forwarder.go
Normal file
414
internal/codespaces/portforwarder/port_forwarder.go
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
package portforwarder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/v2/internal/codespaces/connection"
|
||||
"github.com/microsoft/dev-tunnels/go/tunnels"
|
||||
)
|
||||
|
||||
const (
|
||||
githubSubjectId = "1"
|
||||
InternalPortTag = "InternalPort"
|
||||
UserForwardedPortTag = "UserForwardedPort"
|
||||
)
|
||||
|
||||
const (
|
||||
PrivatePortVisibility = "private"
|
||||
OrgPortVisibility = "org"
|
||||
PublicPortVisibility = "public"
|
||||
)
|
||||
|
||||
const (
|
||||
trafficTypeInput = "input"
|
||||
trafficTypeOutput = "output"
|
||||
)
|
||||
|
||||
type ForwardPortOpts struct {
|
||||
Port int
|
||||
Internal bool
|
||||
KeepAlive bool
|
||||
Visibility string
|
||||
}
|
||||
|
||||
type CodespacesPortForwarder struct {
|
||||
connection connection.CodespaceConnection
|
||||
keepAliveReason chan string
|
||||
}
|
||||
|
||||
type PortForwarder interface {
|
||||
ForwardPortToListener(ctx context.Context, opts ForwardPortOpts, listener *net.TCPListener) error
|
||||
ForwardPort(ctx context.Context, opts ForwardPortOpts) error
|
||||
ConnectToForwardedPort(ctx context.Context, conn io.ReadWriteCloser, opts ForwardPortOpts) error
|
||||
ListPorts(ctx context.Context) ([]*tunnels.TunnelPort, error)
|
||||
UpdatePortVisibility(ctx context.Context, remotePort int, visibility string) error
|
||||
KeepAlive(reason string)
|
||||
GetKeepAliveReason() string
|
||||
Close() error
|
||||
}
|
||||
|
||||
// NewPortForwarder returns a new PortForwarder for the specified codespace.
|
||||
func NewPortForwarder(ctx context.Context, codespaceConnection *connection.CodespaceConnection) (fwd PortForwarder, err error) {
|
||||
return &CodespacesPortForwarder{
|
||||
connection: *codespaceConnection,
|
||||
keepAliveReason: make(chan string, 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ForwardPortToListener forwards the specified port to the given TCP listener.
|
||||
func (fwd *CodespacesPortForwarder) ForwardPortToListener(ctx context.Context, opts ForwardPortOpts, listener *net.TCPListener) error {
|
||||
err := fwd.ForwardPort(ctx, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error forwarding port: %w", err)
|
||||
}
|
||||
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
// Convert the port number to a uint16
|
||||
port, err := convertIntToUint16(opts.Port)
|
||||
if err != nil {
|
||||
done <- fmt.Errorf("error converting port: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure the port is forwarded before connecting
|
||||
err = fwd.connection.TunnelClient.WaitForForwardedPort(ctx, port)
|
||||
if err != nil {
|
||||
done <- fmt.Errorf("wait for forwarded port failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect to the forwarded port
|
||||
err = fwd.connectListenerToForwardedPort(ctx, opts, listener)
|
||||
if err != nil {
|
||||
done <- fmt.Errorf("connect to forwarded port failed: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return fmt.Errorf("error connecting to tunnel: %w", err)
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// ForwardPort informs the host that we would like to forward the given port.
|
||||
func (fwd *CodespacesPortForwarder) ForwardPort(ctx context.Context, opts ForwardPortOpts) error {
|
||||
// Convert the port number to a uint16
|
||||
port, err := convertIntToUint16(opts.Port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error converting port: %w", err)
|
||||
}
|
||||
|
||||
tunnelPort := tunnels.NewTunnelPort(port, "", "", tunnels.TunnelProtocolHttp)
|
||||
|
||||
// If no visibility is provided, Dev Tunnels will use the default (private)
|
||||
if opts.Visibility != "" {
|
||||
// Check if the requested visibility is allowed
|
||||
allowed := false
|
||||
for _, allowedVisibility := range fwd.connection.AllowedPortPrivacySettings {
|
||||
if allowedVisibility == opts.Visibility {
|
||||
allowed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If the requested visibility is not allowed, return an error
|
||||
if !allowed {
|
||||
return fmt.Errorf("visibility %s is not allowed", opts.Visibility)
|
||||
}
|
||||
|
||||
accessControlEntries := visibilityToAccessControlEntries(opts.Visibility)
|
||||
if len(accessControlEntries) > 0 {
|
||||
tunnelPort.AccessControl = &tunnels.TunnelAccessControl{
|
||||
Entries: accessControlEntries,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tag the port as internal or user forwarded so we know if it needs to be shown in the UI
|
||||
if opts.Internal {
|
||||
tunnelPort.Tags = []string{InternalPortTag}
|
||||
} else {
|
||||
tunnelPort.Tags = []string{UserForwardedPortTag}
|
||||
}
|
||||
|
||||
// Create the tunnel port
|
||||
_, err = fwd.connection.TunnelManager.CreateTunnelPort(ctx, fwd.connection.Tunnel, tunnelPort, fwd.connection.Options)
|
||||
if err != nil && !strings.Contains(err.Error(), "409") {
|
||||
return fmt.Errorf("create tunnel port failed: %v", err)
|
||||
}
|
||||
|
||||
// Connect to the tunnel
|
||||
err = fwd.connection.Connect(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect failed: %v", err)
|
||||
}
|
||||
|
||||
// Inform the host that we've forwarded the port locally
|
||||
err = fwd.connection.TunnelClient.RefreshPorts(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("refresh ports failed: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// connectListenerToForwardedPort connects to the forwarded port via a local TCP port.
|
||||
func (fwd *CodespacesPortForwarder) connectListenerToForwardedPort(ctx context.Context, opts ForwardPortOpts, listener *net.TCPListener) (err error) {
|
||||
errc := make(chan error, 1)
|
||||
sendError := func(err error) {
|
||||
// Use non-blocking send, to avoid goroutines getting
|
||||
// stuck in case of concurrent or sequential errors.
|
||||
select {
|
||||
case errc <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := listener.AcceptTCP()
|
||||
if err != nil {
|
||||
sendError(err)
|
||||
return
|
||||
}
|
||||
|
||||
// Connect to the forwarded port in a goroutine so we can accept new connections
|
||||
go func() {
|
||||
if err := fwd.ConnectToForwardedPort(ctx, conn, opts); err != nil {
|
||||
sendError(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for an error or for the context to be cancelled
|
||||
select {
|
||||
case err := <-errc:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err() // canceled
|
||||
}
|
||||
}
|
||||
|
||||
// ConnectToForwardedPort connects to the forwarded port via a given ReadWriteCloser.
|
||||
// Optionally, it detects traffic over the connection and sends activity signals to the server to keep the codespace from shutting down.
|
||||
func (fwd *CodespacesPortForwarder) ConnectToForwardedPort(ctx context.Context, conn io.ReadWriteCloser, opts ForwardPortOpts) error {
|
||||
// Create a traffic monitor to keep the session alive
|
||||
if opts.KeepAlive {
|
||||
conn = newTrafficMonitor(conn, fwd)
|
||||
}
|
||||
|
||||
// Convert the port number to a uint16
|
||||
port, err := convertIntToUint16(opts.Port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error converting port: %w", err)
|
||||
}
|
||||
|
||||
// Connect to the forwarded port
|
||||
err = fwd.connection.TunnelClient.ConnectToForwardedPort(ctx, conn, port)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error connecting to forwarded port: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPorts fetches the list of ports that are currently forwarded.
|
||||
func (fwd *CodespacesPortForwarder) ListPorts(ctx context.Context) (ports []*tunnels.TunnelPort, err error) {
|
||||
ports, err = fwd.connection.TunnelManager.ListTunnelPorts(ctx, fwd.connection.Tunnel, fwd.connection.Options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error listing ports: %w", err)
|
||||
}
|
||||
|
||||
return ports, nil
|
||||
}
|
||||
|
||||
// UpdatePortVisibility changes the visibility (private, org, public) of the specified port.
|
||||
func (fwd *CodespacesPortForwarder) UpdatePortVisibility(ctx context.Context, remotePort int, visibility string) error {
|
||||
tunnelPort, err := fwd.connection.TunnelManager.GetTunnelPort(ctx, fwd.connection.Tunnel, remotePort, fwd.connection.Options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting tunnel port: %w", err)
|
||||
}
|
||||
|
||||
// If the port visibility isn't changing, don't do anything
|
||||
if AccessControlEntriesToVisibility(tunnelPort.AccessControl.Entries) == visibility {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete the existing tunnel port to update
|
||||
err = fwd.connection.TunnelManager.DeleteTunnelPort(ctx, fwd.connection.Tunnel, uint16(remotePort), fwd.connection.Options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error deleting tunnel port: %w", err)
|
||||
}
|
||||
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
// Connect to the tunnel
|
||||
err = fwd.connection.Connect(ctx)
|
||||
if err != nil {
|
||||
done <- fmt.Errorf("connect failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Inform the host that we've deleted the port
|
||||
err = fwd.connection.TunnelClient.RefreshPorts(ctx)
|
||||
if err != nil {
|
||||
done <- fmt.Errorf("refresh ports failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Re-forward the port with the updated visibility
|
||||
err = fwd.ForwardPort(ctx, ForwardPortOpts{Port: remotePort, Visibility: visibility})
|
||||
if err != nil {
|
||||
done <- fmt.Errorf("error forwarding port: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
// Wait for the done channel to be closed
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
// If we fail to re-forward the port, we need to forward again with the original visibility so the port is still accessible
|
||||
_ = fwd.ForwardPort(ctx, ForwardPortOpts{Port: remotePort, Visibility: AccessControlEntriesToVisibility(tunnelPort.AccessControl.Entries)})
|
||||
|
||||
return fmt.Errorf("error connecting to tunnel: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// KeepAlive accepts a reason that is retained if there is no active reason
|
||||
// to send to the server.
|
||||
func (fwd *CodespacesPortForwarder) KeepAlive(reason string) {
|
||||
select {
|
||||
case fwd.keepAliveReason <- reason:
|
||||
default:
|
||||
// there is already an active keep alive reason
|
||||
// so we can ignore this one
|
||||
}
|
||||
}
|
||||
|
||||
// GetKeepAliveReason fetches the keep alive reason from the channel and returns it.
|
||||
func (fwd *CodespacesPortForwarder) GetKeepAliveReason() string {
|
||||
return <-fwd.keepAliveReason
|
||||
}
|
||||
|
||||
// Close closes the port forwarder's tunnel client connection.
|
||||
func (fwd *CodespacesPortForwarder) Close() error {
|
||||
return fwd.connection.Close()
|
||||
}
|
||||
|
||||
// AccessControlEntriesToVisibility converts the access control entries used by Dev Tunnels to a friendly visibility value.
|
||||
func AccessControlEntriesToVisibility(accessControlEntries []tunnels.TunnelAccessControlEntry) string {
|
||||
for _, entry := range accessControlEntries {
|
||||
// If we have the anonymous type (and we're not denying it), it's public
|
||||
if (entry.Type == tunnels.TunnelAccessControlEntryTypeAnonymous) && (!entry.IsDeny) {
|
||||
return PublicPortVisibility
|
||||
}
|
||||
|
||||
// If we have the organizations type (and we're not denying it), it's org
|
||||
if (entry.Provider == string(tunnels.TunnelAuthenticationSchemeGitHub)) && (!entry.IsDeny) {
|
||||
return OrgPortVisibility
|
||||
}
|
||||
}
|
||||
|
||||
// Else, it's private
|
||||
return PrivatePortVisibility
|
||||
}
|
||||
|
||||
// visibilityToAccessControlEntries converts the given visibility to access control entries that can be used by Dev Tunnels.
|
||||
func visibilityToAccessControlEntries(visibility string) []tunnels.TunnelAccessControlEntry {
|
||||
switch visibility {
|
||||
case PublicPortVisibility:
|
||||
return []tunnels.TunnelAccessControlEntry{{
|
||||
Type: tunnels.TunnelAccessControlEntryTypeAnonymous,
|
||||
Subjects: []string{},
|
||||
Scopes: []string{string(tunnels.TunnelAccessScopeConnect)},
|
||||
}}
|
||||
case OrgPortVisibility:
|
||||
return []tunnels.TunnelAccessControlEntry{{
|
||||
Type: tunnels.TunnelAccessControlEntryTypeOrganizations,
|
||||
Subjects: []string{githubSubjectId},
|
||||
Scopes: []string{
|
||||
string(tunnels.TunnelAccessScopeConnect),
|
||||
},
|
||||
Provider: string(tunnels.TunnelAuthenticationSchemeGitHub),
|
||||
}}
|
||||
default:
|
||||
// The tunnel manager doesn't accept empty access control entries, so we need to return a deny entry
|
||||
return []tunnels.TunnelAccessControlEntry{{
|
||||
Type: tunnels.TunnelAccessControlEntryTypeOrganizations,
|
||||
Subjects: []string{githubSubjectId},
|
||||
Scopes: []string{},
|
||||
IsDeny: true,
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// IsInternalPort returns true if the port is internal.
|
||||
func IsInternalPort(port *tunnels.TunnelPort) bool {
|
||||
for _, tag := range port.Tags {
|
||||
if strings.EqualFold(tag, InternalPortTag) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// convertIntToUint16 converts the given int to a uint16.
|
||||
func convertIntToUint16(port int) (uint16, error) {
|
||||
var updatedPort uint16
|
||||
if port >= 0 && port <= 65535 {
|
||||
updatedPort = uint16(port)
|
||||
} else {
|
||||
return 0, fmt.Errorf("invalid port number: %d", port)
|
||||
}
|
||||
|
||||
return updatedPort, nil
|
||||
}
|
||||
|
||||
// trafficMonitor implements io.Reader. It keeps the session alive by notifying
|
||||
// it of the traffic type during Read operations.
|
||||
type trafficMonitor struct {
|
||||
rwc io.ReadWriteCloser
|
||||
fwd PortForwarder
|
||||
}
|
||||
|
||||
// newTrafficMonitor returns a trafficMonitor for the specified codespace connection.
|
||||
// It wraps the provided io.ReaderWriteCloser with its own Read/Write/Close methods.
|
||||
func newTrafficMonitor(rwc io.ReadWriteCloser, fwd PortForwarder) *trafficMonitor {
|
||||
return &trafficMonitor{rwc, fwd}
|
||||
}
|
||||
|
||||
// Read wraps the underlying ReadWriteCloser's Read method and keeps the session alive with the "input" traffic type.
|
||||
func (t *trafficMonitor) Read(p []byte) (n int, err error) {
|
||||
t.fwd.KeepAlive(trafficTypeInput)
|
||||
return t.rwc.Read(p)
|
||||
}
|
||||
|
||||
// Write wraps the underlying ReadWriteCloser's Write method and keeps the session alive with the "output" traffic type.
|
||||
func (t *trafficMonitor) Write(p []byte) (n int, err error) {
|
||||
t.fwd.KeepAlive(trafficTypeOutput)
|
||||
return t.rwc.Write(p)
|
||||
}
|
||||
|
||||
// Close closes the underlying ReadWriteCloser.
|
||||
func (t *trafficMonitor) Close() error {
|
||||
return t.rwc.Close()
|
||||
}
|
||||
139
internal/codespaces/portforwarder/port_forwarder_test.go
Normal file
139
internal/codespaces/portforwarder/port_forwarder_test.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package portforwarder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/codespaces/api"
|
||||
"github.com/cli/cli/v2/internal/codespaces/connection"
|
||||
"github.com/microsoft/dev-tunnels/go/tunnels"
|
||||
)
|
||||
|
||||
func TestNewPortForwarder(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a mock codespace
|
||||
codespace := &api.Codespace{
|
||||
Connection: api.CodespaceConnection{
|
||||
TunnelProperties: api.TunnelProperties{
|
||||
ConnectAccessToken: "connect-token",
|
||||
ManagePortsAccessToken: "manage-ports-token",
|
||||
ServiceUri: "http://global.rel.tunnels.api.visualstudio.com/",
|
||||
TunnelId: "tunnel-id",
|
||||
ClusterId: "usw2",
|
||||
Domain: "domain.com",
|
||||
},
|
||||
},
|
||||
RuntimeConstraints: api.RuntimeConstraints{
|
||||
AllowedPortPrivacySettings: []string{"public", "private"},
|
||||
},
|
||||
}
|
||||
|
||||
// Create the mock HTTP client
|
||||
httpClient, err := connection.NewMockHttpClient()
|
||||
if err != nil {
|
||||
t.Fatalf("NewHttpClient returned an error: %v", err)
|
||||
}
|
||||
|
||||
// Call the function being tested
|
||||
conn, err := connection.NewCodespaceConnection(ctx, codespace, httpClient)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCodespaceConnection returned an error: %v", err)
|
||||
}
|
||||
|
||||
// Create the new port forwarder
|
||||
portForwarder, err := NewPortForwarder(ctx, conn)
|
||||
if err != nil {
|
||||
t.Fatalf("NewPortForwarder returned an error: %v", err)
|
||||
}
|
||||
|
||||
// Check that the port forwarder was created successfully
|
||||
if portForwarder == nil {
|
||||
t.Fatal("NewPortForwarder returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessControlEntriesToVisibility(t *testing.T) {
|
||||
publicAccessControlEntry := []tunnels.TunnelAccessControlEntry{{
|
||||
Type: tunnels.TunnelAccessControlEntryTypeAnonymous,
|
||||
}}
|
||||
orgAccessControlEntry := []tunnels.TunnelAccessControlEntry{{
|
||||
Provider: string(tunnels.TunnelAuthenticationSchemeGitHub),
|
||||
}}
|
||||
privateAccessControlEntry := []tunnels.TunnelAccessControlEntry{}
|
||||
orgIsDenyAccessControlEntry := []tunnels.TunnelAccessControlEntry{{
|
||||
Provider: string(tunnels.TunnelAuthenticationSchemeGitHub),
|
||||
IsDeny: true,
|
||||
}}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
accessControlEntries []tunnels.TunnelAccessControlEntry
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "public",
|
||||
accessControlEntries: publicAccessControlEntry,
|
||||
expected: PublicPortVisibility,
|
||||
},
|
||||
{
|
||||
name: "org",
|
||||
accessControlEntries: orgAccessControlEntry,
|
||||
expected: OrgPortVisibility,
|
||||
},
|
||||
{
|
||||
name: "private",
|
||||
accessControlEntries: privateAccessControlEntry,
|
||||
expected: PrivatePortVisibility,
|
||||
},
|
||||
{
|
||||
name: "orgIsDeny",
|
||||
accessControlEntries: orgIsDenyAccessControlEntry,
|
||||
expected: PrivatePortVisibility,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
visibility := AccessControlEntriesToVisibility(test.accessControlEntries)
|
||||
if visibility != test.expected {
|
||||
t.Errorf("expected %q, got %q", test.expected, visibility)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInternalPort(t *testing.T) {
|
||||
internalPort := &tunnels.TunnelPort{
|
||||
Tags: []string{"InternalPort"},
|
||||
}
|
||||
userForwardedPort := &tunnels.TunnelPort{
|
||||
Tags: []string{"UserForwardedPort"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
port *tunnels.TunnelPort
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "internal",
|
||||
port: internalPort,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "user-forwarded",
|
||||
port: userForwardedPort,
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
isInternal := IsInternalPort(test.port)
|
||||
if isInternal != test.expected {
|
||||
t.Errorf("expected %v, got %v", test.expected, isInternal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -12,10 +12,10 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/internal/codespaces/portforwarder"
|
||||
"github.com/cli/cli/v2/internal/codespaces/rpc/codespace"
|
||||
"github.com/cli/cli/v2/internal/codespaces/rpc/jupyter"
|
||||
"github.com/cli/cli/v2/internal/codespaces/rpc/ssh"
|
||||
"github.com/cli/cli/v2/pkg/liveshare"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
|
@ -31,6 +31,7 @@ const (
|
|||
codespacesInternalSessionName = "CodespacesInternal"
|
||||
clientName = "gh"
|
||||
connectedEventName = "connected"
|
||||
keepAliveEventName = "keepAlive"
|
||||
)
|
||||
|
||||
type StartSSHServerOptions struct {
|
||||
|
|
@ -43,24 +44,26 @@ type Invoker interface {
|
|||
RebuildContainer(ctx context.Context, full bool) error
|
||||
StartSSHServer(ctx context.Context) (int, string, error)
|
||||
StartSSHServerWithOptions(ctx context.Context, options StartSSHServerOptions) (int, string, error)
|
||||
KeepAlive()
|
||||
}
|
||||
|
||||
type invoker struct {
|
||||
conn *grpc.ClientConn
|
||||
session liveshare.LiveshareSession
|
||||
listener net.Listener
|
||||
jupyterClient jupyter.JupyterServerHostClient
|
||||
codespaceClient codespace.CodespaceHostClient
|
||||
sshClient ssh.SshServerHostClient
|
||||
cancelPF context.CancelFunc
|
||||
conn *grpc.ClientConn
|
||||
fwd portforwarder.PortForwarder
|
||||
listener net.Listener
|
||||
jupyterClient jupyter.JupyterServerHostClient
|
||||
codespaceClient codespace.CodespaceHostClient
|
||||
sshClient ssh.SshServerHostClient
|
||||
cancelPF context.CancelFunc
|
||||
keepAliveOverride bool
|
||||
}
|
||||
|
||||
// Connects to the internal RPC server and returns a new invoker for it
|
||||
func CreateInvoker(ctx context.Context, session liveshare.LiveshareSession) (Invoker, error) {
|
||||
func CreateInvoker(ctx context.Context, fwd portforwarder.PortForwarder) (Invoker, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, ConnectionTimeout)
|
||||
defer cancel()
|
||||
|
||||
invoker, err := connect(ctx, session)
|
||||
invoker, err := connect(ctx, fwd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error connecting to internal server: %w", err)
|
||||
}
|
||||
|
|
@ -69,7 +72,7 @@ func CreateInvoker(ctx context.Context, session liveshare.LiveshareSession) (Inv
|
|||
}
|
||||
|
||||
// Finds a free port to listen on and creates a new RPC invoker that connects to that port
|
||||
func connect(ctx context.Context, session liveshare.LiveshareSession) (Invoker, error) {
|
||||
func connect(ctx context.Context, fwd portforwarder.PortForwarder) (Invoker, error) {
|
||||
listener, err := listenTCP()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -77,7 +80,7 @@ func connect(ctx context.Context, session liveshare.LiveshareSession) (Invoker,
|
|||
localAddress := listener.Addr().String()
|
||||
|
||||
invoker := &invoker{
|
||||
session: session,
|
||||
fwd: fwd,
|
||||
listener: listener,
|
||||
}
|
||||
|
||||
|
|
@ -100,8 +103,12 @@ func connect(ctx context.Context, session liveshare.LiveshareSession) (Invoker,
|
|||
|
||||
// Tunnel the remote gRPC server port to the local port
|
||||
go func() {
|
||||
fwd := liveshare.NewPortForwarder(session, codespacesInternalSessionName, codespacesInternalPort, true)
|
||||
ch <- fwd.ForwardToListener(pfctx, listener)
|
||||
// Start forwarding the port locally
|
||||
opts := portforwarder.ForwardPortOpts{
|
||||
Port: codespacesInternalPort,
|
||||
Internal: true,
|
||||
}
|
||||
ch <- fwd.ForwardPortToListener(pfctx, opts, listener)
|
||||
}()
|
||||
|
||||
var conn *grpc.ClientConn
|
||||
|
|
@ -252,6 +259,12 @@ func listenTCP() (*net.TCPListener, error) {
|
|||
return listener, nil
|
||||
}
|
||||
|
||||
// KeepAlive sets a flag to continuously send activity signals to
|
||||
// the codespace even if there is no other activity (e.g. stdio)
|
||||
func (i *invoker) KeepAlive() {
|
||||
i.keepAliveOverride = true
|
||||
}
|
||||
|
||||
// Periodically check whether there is a reason to keep the connection alive, and if so, notify the codespace to do so
|
||||
func (i *invoker) heartbeat(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
|
|
@ -262,7 +275,15 @@ func (i *invoker) heartbeat(ctx context.Context, interval time.Duration) {
|
|||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
reason := i.session.GetKeepAliveReason()
|
||||
reason := ""
|
||||
|
||||
// If the keep alive override flag is set, we don't need to check for activity on the forwarder
|
||||
// Otherwise, grab the reason from the forwarder
|
||||
if i.keepAliveOverride {
|
||||
reason = keepAliveEventName
|
||||
} else {
|
||||
reason = i.fwd.GetKeepAliveReason()
|
||||
}
|
||||
_ = i.notifyCodespaceOfClientActivity(ctx, reason)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,8 @@ func createTestInvoker(t *testing.T, server *mockServer) (Invoker, func(), error
|
|||
listener.Close()
|
||||
}
|
||||
|
||||
invoker, err := CreateInvoker(context.Background(), &rpctest.Session{})
|
||||
// Create a new invoker with a mock port forwarder
|
||||
invoker, err := CreateInvoker(context.Background(), rpctest.PortForwarder{})
|
||||
if err != nil {
|
||||
close()
|
||||
return nil, nil, fmt.Errorf("error connecting to internal server: %w", err)
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
)
|
||||
|
||||
type Channel struct {
|
||||
conn net.Conn
|
||||
}
|
||||
|
||||
func (c *Channel) Read(data []byte) (int, error) {
|
||||
return c.conn.Read(data)
|
||||
}
|
||||
|
||||
func (c *Channel) Write(data []byte) (int, error) {
|
||||
return c.conn.Write(data)
|
||||
}
|
||||
|
||||
func (c *Channel) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *Channel) CloseWrite() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Channel) SendRequest(name string, wantReply bool, payload []byte) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (c *Channel) Stderr() io.ReadWriter {
|
||||
return nil
|
||||
}
|
||||
78
internal/codespaces/rpc/test/port_forwarder.go
Normal file
78
internal/codespaces/rpc/test/port_forwarder.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
|
||||
"github.com/cli/cli/v2/internal/codespaces/portforwarder"
|
||||
"github.com/microsoft/dev-tunnels/go/tunnels"
|
||||
)
|
||||
|
||||
type PortForwarder struct{}
|
||||
|
||||
// Close implements portforwarder.PortForwarder.
|
||||
func (PortForwarder) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConnectToForwardedPort implements portforwarder.PortForwarder.
|
||||
func (PortForwarder) ConnectToForwardedPort(ctx context.Context, conn io.ReadWriteCloser, opts portforwarder.ForwardPortOpts) error {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// ForwardPort implements portforwarder.PortForwarder.
|
||||
func (PortForwarder) ForwardPort(ctx context.Context, opts portforwarder.ForwardPortOpts) error {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// GetKeepAliveReason implements portforwarder.PortForwarder.
|
||||
func (PortForwarder) GetKeepAliveReason() string {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// KeepAlive implements portforwarder.PortForwarder.
|
||||
func (PortForwarder) KeepAlive(reason string) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// ForwardPortToListener implements portforwarder.PortForwarder.
|
||||
func (PortForwarder) ForwardPortToListener(ctx context.Context, opts portforwarder.ForwardPortOpts, listener *net.TCPListener) error {
|
||||
// Start forwarding the port locally
|
||||
hostConn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", opts.Port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Accept the connection from the listener
|
||||
listenerConn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Copy data between the two connections
|
||||
go func() {
|
||||
_, _ = io.Copy(hostConn, listenerConn)
|
||||
hostConn.Close()
|
||||
}()
|
||||
go func() {
|
||||
_, _ = io.Copy(listenerConn, hostConn)
|
||||
listenerConn.Close()
|
||||
}()
|
||||
|
||||
// ForwardPortToListener typically blocks until the context is cancelled so we need to do the same
|
||||
<-ctx.Done()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListPorts implements portforwarder.PortForwarder.
|
||||
func (PortForwarder) ListPorts(ctx context.Context) ([]*tunnels.TunnelPort, error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
// UpdatePortVisibility implements portforwarder.PortForwarder.
|
||||
func (PortForwarder) UpdatePortVisibility(ctx context.Context, remotePort int, visibility string) error {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
package test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/cli/cli/v2/pkg/liveshare"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
type Session struct {
|
||||
channel ssh.Channel
|
||||
}
|
||||
|
||||
func (*Session) Close() error {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
func (*Session) GetSharedServers(context.Context) ([]*liveshare.Port, error) {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
||||
func (s *Session) KeepAlive(reason string) {
|
||||
}
|
||||
|
||||
func (s *Session) GetKeepAliveReason() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Session) StartSharing(ctx context.Context, sessionName string, port int) (liveshare.ChannelID, error) {
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
return liveshare.ChannelID{}, err
|
||||
}
|
||||
s.channel = &Channel{conn}
|
||||
return liveshare.ChannelID{}, nil
|
||||
}
|
||||
|
||||
// Creates mock SSH channel connected to the mock gRPC server
|
||||
func (s *Session) OpenStreamingChannel(ctx context.Context, id liveshare.ChannelID) (ssh.Channel, error) {
|
||||
return s.channel, nil
|
||||
}
|
||||
|
|
@ -19,9 +19,9 @@ type printer interface {
|
|||
// 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, printConnDetails bool,
|
||||
ctx context.Context, p printer, sshArgs []string, command []string, port int, destination string, printConnDetails bool,
|
||||
) error {
|
||||
cmd, connArgs, err := newSSHCommand(ctx, port, destination, sshArgs)
|
||||
cmd, connArgs, err := newSSHCommand(ctx, port, destination, sshArgs, command)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create ssh command: %w", err)
|
||||
}
|
||||
|
|
@ -51,30 +51,24 @@ func Copy(ctx context.Context, scpArgs []string, port int, destination string) e
|
|||
// NewRemoteCommand returns an exec.Cmd that will securely run a shell
|
||||
// command on the remote machine.
|
||||
func NewRemoteCommand(ctx context.Context, tunnelPort int, destination string, sshArgs ...string) (*exec.Cmd, error) {
|
||||
cmd, _, err := newSSHCommand(ctx, tunnelPort, destination, sshArgs)
|
||||
sshArgs, command, err := ParseSSHArgs(sshArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd, _, err := newSSHCommand(ctx, tunnelPort, destination, sshArgs, command)
|
||||
return cmd, err
|
||||
}
|
||||
|
||||
// newSSHCommand populates an exec.Cmd to run a command (or if blank,
|
||||
// an interactive shell) over ssh.
|
||||
func newSSHCommand(ctx context.Context, port int, dst string, cmdArgs []string) (*exec.Cmd, []string, error) {
|
||||
func newSSHCommand(ctx context.Context, port int, dst string, cmdArgs []string, command []string) (*exec.Cmd, []string, error) {
|
||||
connArgs := []string{
|
||||
"-p", strconv.Itoa(port),
|
||||
"-o", "NoHostAuthenticationForLocalhost=yes",
|
||||
"-o", "PasswordAuthentication=no",
|
||||
}
|
||||
|
||||
// The ssh command syntax is: ssh [flags] user@host command [args...]
|
||||
// There is no way to specify the user@host destination as a flag.
|
||||
// Unfortunately, that means we need to know which user-provided words are
|
||||
// SSH flags and which are command arguments so that we can place
|
||||
// them before or after the destination, and that means we need to know all
|
||||
// the flags and their arities.
|
||||
cmdArgs, command, err := parseSSHArgs(cmdArgs)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
cmdArgs = append(cmdArgs, connArgs...)
|
||||
cmdArgs = append(cmdArgs, "-C") // Compression
|
||||
cmdArgs = append(cmdArgs, dst) // user@host
|
||||
|
|
@ -96,7 +90,14 @@ func newSSHCommand(ctx context.Context, port int, dst string, cmdArgs []string)
|
|||
return cmd, connArgs, nil
|
||||
}
|
||||
|
||||
func parseSSHArgs(args []string) (cmdArgs, command []string, err error) {
|
||||
// ParseSSHArgs parses the given array of arguments into two distinct slices of flags and command.
|
||||
// The ssh command syntax is: ssh [flags] user@host command [args...]
|
||||
// There is no way to specify the user@host destination as a flag.
|
||||
// Unfortunately, that means we need to know which user-provided words are
|
||||
// SSH flags and which are command arguments so that we can place
|
||||
// them before or after the destination, and that means we need to know all
|
||||
// the flags and their arities.
|
||||
func ParseSSHArgs(args []string) (cmdArgs, command []string, err error) {
|
||||
return parseArgs(args, "bcDeFIiLlmOopRSWw")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ func TestParseSSHArgs(t *testing.T) {
|
|||
}
|
||||
|
||||
for _, tcase := range testCases {
|
||||
args, command, err := parseSSHArgs(tcase.Args)
|
||||
args, command, err := ParseSSHArgs(tcase.Args)
|
||||
|
||||
checkParseResult(t, tcase, args, command, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,13 +6,12 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/internal/codespaces/api"
|
||||
"github.com/cli/cli/v2/internal/codespaces/portforwarder"
|
||||
"github.com/cli/cli/v2/internal/codespaces/rpc"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/liveshare"
|
||||
)
|
||||
|
||||
// PostCreateStateStatus is a string value representing the different statuses a state can have.
|
||||
|
|
@ -39,17 +38,16 @@ type PostCreateState struct {
|
|||
// and calls the supplied poller for each batch of state changes.
|
||||
// It runs until it encounters an error, including cancellation of the context.
|
||||
func PollPostCreateStates(ctx context.Context, progress progressIndicator, apiClient apiClient, codespace *api.Codespace, poller func([]PostCreateState)) (err error) {
|
||||
noopLogger := log.New(io.Discard, "", 0)
|
||||
|
||||
session, err := ConnectToLiveshare(ctx, progress, noopLogger, apiClient, codespace)
|
||||
codespaceConnection, err := GetCodespaceConnection(ctx, progress, apiClient, codespace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect to codespace: %w", err)
|
||||
return fmt.Errorf("error connecting to codespace: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := session.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
}()
|
||||
|
||||
fwd, err := portforwarder.NewPortForwarder(ctx, codespaceConnection)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create port forwarder: %w", err)
|
||||
}
|
||||
defer safeClose(fwd, &err)
|
||||
|
||||
// Ensure local port is listening before client (getPostCreateOutput) connects.
|
||||
listen, localPort, err := ListenTCP(0, false)
|
||||
|
|
@ -58,7 +56,7 @@ func PollPostCreateStates(ctx context.Context, progress progressIndicator, apiCl
|
|||
}
|
||||
|
||||
progress.StartProgressIndicatorWithLabel("Fetching SSH Details")
|
||||
invoker, err := rpc.CreateInvoker(ctx, session)
|
||||
invoker, err := rpc.CreateInvoker(ctx, fwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -73,8 +71,11 @@ func PollPostCreateStates(ctx context.Context, progress progressIndicator, apiCl
|
|||
progress.StartProgressIndicatorWithLabel("Fetching status")
|
||||
tunnelClosed := make(chan error, 1) // buffered to avoid sender stuckness
|
||||
go func() {
|
||||
fwd := liveshare.NewPortForwarder(session, "sshd", remoteSSHServerPort, false)
|
||||
tunnelClosed <- fwd.ForwardToListener(ctx, listen) // error is non-nil
|
||||
opts := portforwarder.ForwardPortOpts{
|
||||
Port: remoteSSHServerPort,
|
||||
Internal: true,
|
||||
}
|
||||
tunnelClosed <- fwd.ForwardPortToListener(ctx, opts, listen)
|
||||
}()
|
||||
|
||||
t := time.NewTicker(1 * time.Second)
|
||||
|
|
|
|||
869
internal/config/auth_config_test.go
Normal file
869
internal/config/auth_config_test.go
Normal file
|
|
@ -0,0 +1,869 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/config/migration"
|
||||
"github.com/cli/cli/v2/internal/keyring"
|
||||
ghConfig "github.com/cli/go-gh/v2/pkg/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Note that NewIsolatedTestConfig sets up a Mock keyring as well
|
||||
func newTestAuthConfig(t *testing.T) *AuthConfig {
|
||||
cfg, _ := NewIsolatedTestConfig(t)
|
||||
return &AuthConfig{cfg: cfg.cfg}
|
||||
}
|
||||
|
||||
func TestTokenFromKeyring(t *testing.T) {
|
||||
// Given a keyring that contains a token for a host
|
||||
authCfg := newTestAuthConfig(t)
|
||||
require.NoError(t, keyring.Set(keyringServiceName("github.com"), "", "test-token"))
|
||||
|
||||
// When we get the token from the auth config
|
||||
token, err := authCfg.TokenFromKeyring("github.com")
|
||||
|
||||
// Then it returns successfully with the correct token
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-token", token)
|
||||
}
|
||||
|
||||
func TestTokenFromKeyringForUser(t *testing.T) {
|
||||
// Given a keyring that contains a token for a host with a specific user
|
||||
authCfg := newTestAuthConfig(t)
|
||||
require.NoError(t, keyring.Set(keyringServiceName("github.com"), "test-user", "test-token"))
|
||||
|
||||
// When we get the token from the auth config
|
||||
token, err := authCfg.TokenFromKeyringForUser("github.com", "test-user")
|
||||
|
||||
// Then it returns successfully with the correct token
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-token", token)
|
||||
}
|
||||
|
||||
func TestTokenFromKeyringForUserErrorsIfUsernameIsBlank(t *testing.T) {
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
// When we get the token from the keyring for an empty username
|
||||
_, err := authCfg.TokenFromKeyringForUser("github.com", "")
|
||||
|
||||
// Then it returns an error
|
||||
require.ErrorContains(t, err, "username cannot be blank")
|
||||
}
|
||||
|
||||
func TestTokenStoredInConfig(t *testing.T) {
|
||||
// When the user has logged in insecurely
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user", "test-token", "", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we get the token
|
||||
token, source := authCfg.ActiveToken("github.com")
|
||||
|
||||
// Then the token is successfully fetched
|
||||
// and the source is set to oauth_token but this isn't great:
|
||||
// https://github.com/cli/go-gh/issues/94
|
||||
require.Equal(t, "test-token", token)
|
||||
require.Equal(t, oauthTokenKey, source)
|
||||
}
|
||||
|
||||
func TestTokenStoredInEnv(t *testing.T) {
|
||||
// When the user is authenticated via env var
|
||||
authCfg := newTestAuthConfig(t)
|
||||
t.Setenv("GH_TOKEN", "test-token")
|
||||
|
||||
// When we get the token
|
||||
token, source := authCfg.ActiveToken("github.com")
|
||||
|
||||
// Then the token is successfully fetched
|
||||
// and the source is set to the name of the env var
|
||||
require.Equal(t, "test-token", token)
|
||||
require.Equal(t, "GH_TOKEN", source)
|
||||
}
|
||||
|
||||
func TestTokenStoredInKeyring(t *testing.T) {
|
||||
// When the user has logged in securely
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user", "test-token", "", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we get the token
|
||||
token, source := authCfg.ActiveToken("github.com")
|
||||
|
||||
// Then the token is successfully fetched
|
||||
// and the source is set to keyring
|
||||
require.Equal(t, "test-token", token)
|
||||
require.Equal(t, "keyring", source)
|
||||
}
|
||||
|
||||
func TestTokenFromKeyringNonExistent(t *testing.T) {
|
||||
// Given a keyring that doesn't contain any tokens
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
// When we try to get a token from the auth config
|
||||
_, err := authCfg.TokenFromKeyring("github.com")
|
||||
|
||||
// Then it returns failure bubbling the ErrNotFound
|
||||
require.ErrorContains(t, err, "secret not found in keyring")
|
||||
}
|
||||
|
||||
func TestHasEnvTokenWithoutAnyEnvToken(t *testing.T) {
|
||||
// Given we have no env set
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
// When we check if it has an env token
|
||||
hasEnvToken := authCfg.HasEnvToken()
|
||||
|
||||
// Then it returns false
|
||||
require.False(t, hasEnvToken, "expected not to have env token")
|
||||
}
|
||||
|
||||
func TestHasEnvTokenWithEnvToken(t *testing.T) {
|
||||
// Given we have an env token set
|
||||
// Note that any valid env var for tokens will do, not just GH_ENTERPRISE_TOKEN
|
||||
authCfg := newTestAuthConfig(t)
|
||||
t.Setenv("GH_ENTERPRISE_TOKEN", "test-token")
|
||||
|
||||
// When we check if it has an env token
|
||||
hasEnvToken := authCfg.HasEnvToken()
|
||||
|
||||
// Then it returns true
|
||||
require.True(t, hasEnvToken, "expected to have env token")
|
||||
}
|
||||
|
||||
func TestHasEnvTokenWithNoEnvTokenButAConfigVar(t *testing.T) {
|
||||
t.Skip("this test is explicitly breaking some implementation assumptions")
|
||||
|
||||
// Given a token in the config
|
||||
authCfg := newTestAuthConfig(t)
|
||||
// Using example.com here will cause the token to be returned from the config
|
||||
_, err := authCfg.Login("example.com", "test-user", "test-token", "", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we check if it has an env token
|
||||
hasEnvToken := authCfg.HasEnvToken()
|
||||
|
||||
// Then it SHOULD return false
|
||||
require.False(t, hasEnvToken, "expected not to have env token")
|
||||
}
|
||||
|
||||
func TestUserNotLoggedIn(t *testing.T) {
|
||||
// Given we have not logged in
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
// When we get the user
|
||||
_, err := authCfg.ActiveUser("github.com")
|
||||
|
||||
// Then it returns failure, bubbling the KeyNotFoundError
|
||||
var keyNotFoundError *ghConfig.KeyNotFoundError
|
||||
require.ErrorAs(t, err, &keyNotFoundError)
|
||||
}
|
||||
|
||||
func TestHostsIncludesEnvVar(t *testing.T) {
|
||||
// Given the GH_HOST env var is set
|
||||
authCfg := newTestAuthConfig(t)
|
||||
t.Setenv("GH_HOST", "ghe.io")
|
||||
|
||||
// When we get the hosts
|
||||
hosts := authCfg.Hosts()
|
||||
|
||||
// Then the host in the env var is included
|
||||
require.Contains(t, hosts, "ghe.io")
|
||||
}
|
||||
|
||||
func TestDefaultHostFromEnvVar(t *testing.T) {
|
||||
// Given the GH_HOST env var is set
|
||||
authCfg := newTestAuthConfig(t)
|
||||
t.Setenv("GH_HOST", "ghe.io")
|
||||
|
||||
// When we get the DefaultHost
|
||||
defaultHost, source := authCfg.DefaultHost()
|
||||
|
||||
// Then the returned host and source are using the env var
|
||||
require.Equal(t, "ghe.io", defaultHost)
|
||||
require.Equal(t, "GH_HOST", source)
|
||||
}
|
||||
|
||||
func TestDefaultHostNotLoggedIn(t *testing.T) {
|
||||
// Given we are not logged in
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
// When we get the DefaultHost
|
||||
defaultHost, source := authCfg.DefaultHost()
|
||||
|
||||
// Then the returned host is always github.com
|
||||
require.Equal(t, "github.com", defaultHost)
|
||||
require.Equal(t, "default", source)
|
||||
}
|
||||
|
||||
func TestDefaultHostLoggedInToOnlyOneHost(t *testing.T) {
|
||||
// Given we are logged into one host (not github.com to differentiate from the fallback)
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("ghe.io", "test-user", "test-token", "", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we get the DefaultHost
|
||||
defaultHost, source := authCfg.DefaultHost()
|
||||
|
||||
// Then the returned host is that logged in host and the source is the hosts config
|
||||
require.Equal(t, "ghe.io", defaultHost)
|
||||
require.Equal(t, hostsKey, source)
|
||||
}
|
||||
|
||||
func TestLoginSecureStorageUsesKeyring(t *testing.T) {
|
||||
// Given a usable keyring
|
||||
authCfg := newTestAuthConfig(t)
|
||||
host := "github.com"
|
||||
user := "test-user"
|
||||
token := "test-token"
|
||||
|
||||
// When we login with secure storage
|
||||
insecureStorageUsed, err := authCfg.Login(host, user, token, "", true)
|
||||
|
||||
// Then it returns success, notes that insecure storage was not used, and stores the token in the keyring
|
||||
require.NoError(t, err)
|
||||
require.False(t, insecureStorageUsed, "expected to use secure storage")
|
||||
|
||||
gotToken, err := keyring.Get(keyringServiceName(host), "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token, gotToken)
|
||||
|
||||
gotToken, err = keyring.Get(keyringServiceName(host), user)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token, gotToken)
|
||||
}
|
||||
|
||||
func TestLoginSecureStorageRemovesOldInsecureConfigToken(t *testing.T) {
|
||||
// Given a usable keyring and an oauth token in the config
|
||||
authCfg := newTestAuthConfig(t)
|
||||
authCfg.cfg.Set([]string{hostsKey, "github.com", oauthTokenKey}, "old-token")
|
||||
|
||||
// When we login with secure storage
|
||||
_, err := authCfg.Login("github.com", "test-user", "test-token", "", true)
|
||||
|
||||
// Then it returns success, having also removed the old token from the config
|
||||
require.NoError(t, err)
|
||||
requireNoKey(t, authCfg.cfg, []string{hostsKey, "github.com", oauthTokenKey})
|
||||
}
|
||||
|
||||
func TestLoginSecureStorageWithErrorFallsbackAndReports(t *testing.T) {
|
||||
// Given a keyring that errors
|
||||
authCfg := newTestAuthConfig(t)
|
||||
keyring.MockInitWithError(errors.New("test-explosion"))
|
||||
|
||||
// When we login with secure storage
|
||||
insecureStorageUsed, err := authCfg.Login("github.com", "test-user", "test-token", "", true)
|
||||
|
||||
// Then it returns success, reports that insecure storage was used, and stores the token in the config
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, insecureStorageUsed, "expected to use insecure storage")
|
||||
requireKeyWithValue(t, authCfg.cfg, []string{hostsKey, "github.com", oauthTokenKey}, "test-token")
|
||||
}
|
||||
|
||||
func TestLoginInsecureStorage(t *testing.T) {
|
||||
// Given we are not logged in
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
// When we login with insecure storage
|
||||
insecureStorageUsed, err := authCfg.Login("github.com", "test-user", "test-token", "", false)
|
||||
|
||||
// Then it returns success, notes that insecure storage was used, and stores the token in the config
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, insecureStorageUsed, "expected to use insecure storage")
|
||||
requireKeyWithValue(t, authCfg.cfg, []string{hostsKey, "github.com", oauthTokenKey}, "test-token")
|
||||
}
|
||||
|
||||
func TestLoginSetsUserForProvidedHost(t *testing.T) {
|
||||
// Given we are not logged in
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
// When we login
|
||||
_, err := authCfg.Login("github.com", "test-user", "test-token", "ssh", false)
|
||||
|
||||
// Then it returns success and the user is set
|
||||
require.NoError(t, err)
|
||||
|
||||
user, err := authCfg.ActiveUser("github.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-user", user)
|
||||
}
|
||||
|
||||
func TestLoginSetsGitProtocolForProvidedHost(t *testing.T) {
|
||||
// Given we are logged in
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user", "test-token", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we get the host git protocol
|
||||
hostProtocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", gitProtocolKey})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then it returns the git protocol we provided on login
|
||||
require.Equal(t, "ssh", hostProtocol)
|
||||
}
|
||||
|
||||
func TestLoginAddsHostIfNotAlreadyAdded(t *testing.T) {
|
||||
// Given we are logged in
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user", "test-token", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we get the hosts
|
||||
hosts := authCfg.Hosts()
|
||||
|
||||
// Then it includes our logged in host
|
||||
require.Contains(t, hosts, "github.com")
|
||||
}
|
||||
|
||||
// This test mimics the behaviour of logging in with a token, not providing
|
||||
// a git protocol, and using secure storage.
|
||||
func TestLoginAddsUserToConfigWithoutGitProtocolAndWithSecureStorage(t *testing.T) {
|
||||
// Given we are not logged in
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
// When we log in without git protocol and with secure storage
|
||||
_, err := authCfg.Login("github.com", "test-user", "test-token", "", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then the username is added under the users config
|
||||
users, err := authCfg.cfg.Keys([]string{hostsKey, "github.com", usersKey})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, users, "test-user")
|
||||
}
|
||||
|
||||
func TestLogoutRemovesHostAndKeyringToken(t *testing.T) {
|
||||
// Given we are logged into a host
|
||||
authCfg := newTestAuthConfig(t)
|
||||
host := "github.com"
|
||||
user := "test-user"
|
||||
token := "test-token"
|
||||
|
||||
_, err := authCfg.Login(host, user, token, "ssh", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we logout
|
||||
err = authCfg.Logout(host, user)
|
||||
|
||||
// Then we return success, and the host and token are removed from the config and keyring
|
||||
require.NoError(t, err)
|
||||
|
||||
requireNoKey(t, authCfg.cfg, []string{hostsKey, host})
|
||||
_, err = keyring.Get(keyringServiceName(host), "")
|
||||
require.ErrorContains(t, err, "secret not found in keyring")
|
||||
_, err = keyring.Get(keyringServiceName(host), user)
|
||||
require.ErrorContains(t, err, "secret not found in keyring")
|
||||
}
|
||||
|
||||
func TestLogoutOfActiveUserSwitchesUserIfPossible(t *testing.T) {
|
||||
// Given we have two accounts logged into a host
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "inactive-user", "test-token-1", "ssh", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = authCfg.Login("github.com", "active-user", "test-token-2", "https", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we logout of the active user
|
||||
err = authCfg.Logout("github.com", "active-user")
|
||||
|
||||
// Then we return success and the inactive user is now active
|
||||
require.NoError(t, err)
|
||||
activeUser, err := authCfg.ActiveUser("github.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "inactive-user", activeUser)
|
||||
|
||||
token, err := authCfg.TokenFromKeyring("github.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-token-1", token)
|
||||
|
||||
usersForHost := authCfg.UsersForHost("github.com")
|
||||
require.NotContains(t, "active-user", usersForHost)
|
||||
}
|
||||
|
||||
func TestLogoutOfInactiveUserDoesNotSwitchUser(t *testing.T) {
|
||||
// Given we have two accounts logged into a host
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "inactive-user-1", "test-token-1.1", "ssh", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = authCfg.Login("github.com", "inactive-user-2", "test-token-1.2", "ssh", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = authCfg.Login("github.com", "active-user", "test-token-2", "https", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we logout of an inactive user
|
||||
err = authCfg.Logout("github.com", "inactive-user-1")
|
||||
|
||||
// Then we return success and the active user is still active
|
||||
require.NoError(t, err)
|
||||
activeUser, err := authCfg.ActiveUser("github.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "active-user", activeUser)
|
||||
}
|
||||
|
||||
// Note that I'm not sure this test enforces particularly desirable behaviour
|
||||
// since it leads users to believe a token has been removed when really
|
||||
// that might have failed for some reason.
|
||||
//
|
||||
// The original intention here is that if the logout fails, the user can't
|
||||
// really do anything to recover. On the other hand, a user might
|
||||
// want to rectify this manually, for example if there were on a shared machine.
|
||||
func TestLogoutIgnoresErrorsFromConfigAndKeyring(t *testing.T) {
|
||||
// Given we have keyring that errors, and a config that
|
||||
// doesn't even have a hosts key (which would cause Remove to fail)
|
||||
keyring.MockInitWithError(errors.New("test-explosion"))
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
// When we logout
|
||||
err := authCfg.Logout("github.com", "test-user")
|
||||
|
||||
// Then it returns success anyway, suppressing the errors
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestSwitchUserMakesSecureTokenActive(t *testing.T) {
|
||||
// Given we have a user with a secure token
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", true)
|
||||
require.NoError(t, err)
|
||||
_, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we switch to that user
|
||||
require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1"))
|
||||
|
||||
// Their secure token is now active
|
||||
token, err := authCfg.TokenFromKeyring("github.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-token-1", token)
|
||||
}
|
||||
|
||||
func TestSwitchUserMakesInsecureTokenActive(t *testing.T) {
|
||||
// Given we have a user with an insecure token
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
_, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we switch to that user
|
||||
require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1"))
|
||||
|
||||
// Their insecure token is now active
|
||||
token, source := authCfg.ActiveToken("github.com")
|
||||
require.Equal(t, "test-token-1", token)
|
||||
require.Equal(t, oauthTokenKey, source)
|
||||
}
|
||||
|
||||
func TestSwitchUserUpdatesTheActiveUser(t *testing.T) {
|
||||
// Given we have two users logged into a host
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
_, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we switch to the other user
|
||||
require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1"))
|
||||
|
||||
// Then the active user is updated
|
||||
activeUser, err := authCfg.ActiveUser("github.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-user-1", activeUser)
|
||||
}
|
||||
|
||||
func TestSwitchUserErrorsImmediatelyIfTheActiveTokenComesFromEnvironment(t *testing.T) {
|
||||
// Given we have a token in the env
|
||||
authCfg := newTestAuthConfig(t)
|
||||
t.Setenv("GH_TOKEN", "unimportant-test-value")
|
||||
_, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", true)
|
||||
require.NoError(t, err)
|
||||
_, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we switch to a user
|
||||
err = authCfg.SwitchUser("github.com", "test-user-1")
|
||||
|
||||
// Then it errors immediately with an informative message
|
||||
require.ErrorContains(t, err, "currently active token for github.com is from GH_TOKEN")
|
||||
}
|
||||
|
||||
func TestSwitchUserErrorsAndRestoresUserAndInsecureConfigUnderFailure(t *testing.T) {
|
||||
// Given we have a user but no token can be found (because we deleted them, simulating an error case)
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", true)
|
||||
require.NoError(t, err)
|
||||
_, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, keyring.Delete(keyringServiceName("github.com"), "test-user-1"))
|
||||
|
||||
// When we switch to the user
|
||||
err = authCfg.SwitchUser("github.com", "test-user-1")
|
||||
|
||||
// Then it returns an error
|
||||
require.EqualError(t, err, "no token found for test-user-1")
|
||||
|
||||
// And restores the previous state
|
||||
activeUser, err := authCfg.ActiveUser("github.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-user-2", activeUser)
|
||||
|
||||
token, source := authCfg.ActiveToken("github.com")
|
||||
require.Equal(t, "test-token-2", token)
|
||||
require.Equal(t, "oauth_token", source)
|
||||
}
|
||||
|
||||
func TestSwitchUserErrorsAndRestoresUserAndKeyringUnderFailure(t *testing.T) {
|
||||
// Given we have a user but no token can be found (because we deleted them, simulating an error case)
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
_, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, authCfg.cfg.Remove([]string{hostsKey, "github.com", usersKey, "test-user-1", oauthTokenKey}))
|
||||
|
||||
// When we switch to the user
|
||||
err = authCfg.SwitchUser("github.com", "test-user-1")
|
||||
|
||||
// Then it returns an error
|
||||
require.EqualError(t, err, "no token found for test-user-1")
|
||||
|
||||
// And restores the previous state
|
||||
activeUser, err := authCfg.ActiveUser("github.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-user-2", activeUser)
|
||||
|
||||
token, source := authCfg.ActiveToken("github.com")
|
||||
require.Equal(t, "test-token-2", token)
|
||||
require.Equal(t, "keyring", source)
|
||||
}
|
||||
|
||||
func TestSwitchClearsActiveSecureTokenWhenSwitchingToInsecureUser(t *testing.T) {
|
||||
// Given we have an active secure token
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
_, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we switch to an insecure user
|
||||
require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1"))
|
||||
|
||||
// Then the active secure token is cleared
|
||||
_, err = authCfg.TokenFromKeyring("github.com")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSwitchClearsActiveInsecureTokenWhenSwitchingToSecureUser(t *testing.T) {
|
||||
// Given we have an active insecure token
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user-1", "test-token-1", "ssh", true)
|
||||
require.NoError(t, err)
|
||||
_, err = authCfg.Login("github.com", "test-user-2", "test-token-2", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we switch to a secure user
|
||||
require.NoError(t, authCfg.SwitchUser("github.com", "test-user-1"))
|
||||
|
||||
// Then the active insecure token is cleared
|
||||
requireNoKey(t, authCfg.cfg, []string{hostsKey, "github.com", oauthTokenKey})
|
||||
}
|
||||
|
||||
func TestUsersForHostNoHost(t *testing.T) {
|
||||
// Given we have a config with no hosts
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
// When we get the users for a host that doesn't exist
|
||||
users := authCfg.UsersForHost("github.com")
|
||||
|
||||
// Then it returns nil
|
||||
require.Nil(t, users)
|
||||
}
|
||||
|
||||
func TestUsersForHostWithUsers(t *testing.T) {
|
||||
// Given we have a config with a host and users
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user-1", "test-token", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
_, err = authCfg.Login("github.com", "test-user-2", "test-token", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we get the users for that host
|
||||
users := authCfg.UsersForHost("github.com")
|
||||
|
||||
// Then it succeeds and returns the users
|
||||
require.Equal(t, []string{"test-user-1", "test-user-2"}, users)
|
||||
}
|
||||
|
||||
func TestTokenForUserSecureLogin(t *testing.T) {
|
||||
// Given a user has logged in securely
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user-1", "test-token", "ssh", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we get the token
|
||||
token, source, err := authCfg.TokenForUser("github.com", "test-user-1")
|
||||
|
||||
// Then it returns the token and the source as keyring
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-token", token)
|
||||
require.Equal(t, "keyring", source)
|
||||
}
|
||||
|
||||
func TestTokenForUserInsecureLogin(t *testing.T) {
|
||||
// Given a user has logged in insecurely
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := authCfg.Login("github.com", "test-user-1", "test-token", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we get the token
|
||||
token, source, err := authCfg.TokenForUser("github.com", "test-user-1")
|
||||
|
||||
// Then it returns the token and the source as oauth_token
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-token", token)
|
||||
require.Equal(t, "oauth_token", source)
|
||||
}
|
||||
|
||||
func TestTokenForUserNotFoundErrors(t *testing.T) {
|
||||
// Given a user has not logged in
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
// When we get the token
|
||||
_, _, err := authCfg.TokenForUser("github.com", "test-user-1")
|
||||
|
||||
// Then it returns an error
|
||||
require.EqualError(t, err, "no token found for 'test-user-1'")
|
||||
}
|
||||
|
||||
func requireKeyWithValue(t *testing.T, cfg *ghConfig.Config, keys []string, value string) {
|
||||
t.Helper()
|
||||
|
||||
actual, err := cfg.Get(keys)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, value, actual)
|
||||
}
|
||||
|
||||
func requireNoKey(t *testing.T, cfg *ghConfig.Config, keys []string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := cfg.Get(keys)
|
||||
var keyNotFoundError *ghConfig.KeyNotFoundError
|
||||
require.ErrorAs(t, err, &keyNotFoundError)
|
||||
}
|
||||
|
||||
// Post migration tests
|
||||
|
||||
func TestUserWorksRightAfterMigration(t *testing.T) {
|
||||
// Given we have logged in before migration
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := preMigrationLogin(authCfg, "github.com", "test-user", "test-token", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we migrate
|
||||
var m migration.MultiAccount
|
||||
c := cfg{authCfg.cfg}
|
||||
require.NoError(t, c.Migrate(m))
|
||||
|
||||
// Then we can still get the user correctly
|
||||
user, err := authCfg.ActiveUser("github.com")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-user", user)
|
||||
}
|
||||
|
||||
func TestGitProtocolWorksRightAfterMigration(t *testing.T) {
|
||||
// Given we have logged in before migration with a non-default git protocol
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := preMigrationLogin(authCfg, "github.com", "test-user", "test-token", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we migrate
|
||||
var m migration.MultiAccount
|
||||
c := cfg{authCfg.cfg}
|
||||
require.NoError(t, c.Migrate(m))
|
||||
|
||||
// Then we can still get the git protocol correctly
|
||||
gitProtocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", gitProtocolKey})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ssh", gitProtocol)
|
||||
}
|
||||
|
||||
func TestHostsWorksRightAfterMigration(t *testing.T) {
|
||||
// Given we have logged in before migration
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := preMigrationLogin(authCfg, "ghe.io", "test-user", "test-token", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we migrate
|
||||
var m migration.MultiAccount
|
||||
c := cfg{authCfg.cfg}
|
||||
require.NoError(t, c.Migrate(m))
|
||||
|
||||
// Then we can still get the hosts correctly
|
||||
hosts := authCfg.Hosts()
|
||||
require.Contains(t, hosts, "ghe.io")
|
||||
}
|
||||
|
||||
func TestDefaultHostWorksRightAfterMigration(t *testing.T) {
|
||||
// Given we have logged in before migration to an enterprise host
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := preMigrationLogin(authCfg, "ghe.io", "test-user", "test-token", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we migrate
|
||||
var m migration.MultiAccount
|
||||
c := cfg{authCfg.cfg}
|
||||
require.NoError(t, c.Migrate(m))
|
||||
|
||||
// Then the default host is still the enterprise host
|
||||
defaultHost, source := authCfg.DefaultHost()
|
||||
require.Equal(t, "ghe.io", defaultHost)
|
||||
require.Equal(t, hostsKey, source)
|
||||
}
|
||||
|
||||
func TestTokenWorksRightAfterMigration(t *testing.T) {
|
||||
// Given we have logged in before migration
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := preMigrationLogin(authCfg, "github.com", "test-user", "test-token", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we migrate
|
||||
var m migration.MultiAccount
|
||||
c := cfg{authCfg.cfg}
|
||||
require.NoError(t, c.Migrate(m))
|
||||
|
||||
// Then we can still get the token correctly
|
||||
token, source := authCfg.ActiveToken("github.com")
|
||||
require.Equal(t, "test-token", token)
|
||||
require.Equal(t, oauthTokenKey, source)
|
||||
}
|
||||
|
||||
func TestLogoutRightAfterMigrationRemovesHost(t *testing.T) {
|
||||
// Given we have logged in before migration
|
||||
authCfg := newTestAuthConfig(t)
|
||||
host := "github.com"
|
||||
user := "test-user"
|
||||
token := "test-token"
|
||||
|
||||
_, err := preMigrationLogin(authCfg, host, user, token, "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we migrate and logout
|
||||
var m migration.MultiAccount
|
||||
c := cfg{authCfg.cfg}
|
||||
require.NoError(t, c.Migrate(m))
|
||||
|
||||
require.NoError(t, authCfg.Logout(host, user))
|
||||
|
||||
// Then the host is removed from the config
|
||||
requireNoKey(t, authCfg.cfg, []string{hostsKey, "github.com"})
|
||||
}
|
||||
|
||||
func TestLoginInsecurePostMigrationUsesConfigForToken(t *testing.T) {
|
||||
// Given we have not logged in
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
// When we migrate and login with insecure storage
|
||||
var m migration.MultiAccount
|
||||
c := cfg{authCfg.cfg}
|
||||
require.NoError(t, c.Migrate(m))
|
||||
|
||||
insecureStorageUsed, err := authCfg.Login("github.com", "test-user", "test-token", "", false)
|
||||
|
||||
// Then it returns success, notes that insecure storage was used, and stores the token in the config
|
||||
// both under the host and under the user
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, insecureStorageUsed, "expected to use insecure storage")
|
||||
requireKeyWithValue(t, authCfg.cfg, []string{hostsKey, "github.com", oauthTokenKey}, "test-token")
|
||||
requireKeyWithValue(t, authCfg.cfg, []string{hostsKey, "github.com", usersKey, "test-user", oauthTokenKey}, "test-token")
|
||||
}
|
||||
|
||||
func TestLoginPostMigrationSetsGitProtocol(t *testing.T) {
|
||||
// Given we have logged in after migration
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
var m migration.MultiAccount
|
||||
c := cfg{authCfg.cfg}
|
||||
require.NoError(t, c.Migrate(m))
|
||||
|
||||
_, err := authCfg.Login("github.com", "test-user", "test-token", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we get the host git protocol
|
||||
hostProtocol, err := authCfg.cfg.Get([]string{hostsKey, "github.com", gitProtocolKey})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Then it returns the git protocol we provided on login
|
||||
require.Equal(t, "ssh", hostProtocol)
|
||||
}
|
||||
|
||||
func TestLoginPostMigrationSetsUser(t *testing.T) {
|
||||
// Given we have logged in after migration
|
||||
authCfg := newTestAuthConfig(t)
|
||||
|
||||
var m migration.MultiAccount
|
||||
c := cfg{authCfg.cfg}
|
||||
require.NoError(t, c.Migrate(m))
|
||||
|
||||
_, err := authCfg.Login("github.com", "test-user", "test-token", "ssh", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we get the user
|
||||
user, err := authCfg.ActiveUser("github.com")
|
||||
|
||||
// Then it returns success and the user we provided on login
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "test-user", user)
|
||||
}
|
||||
|
||||
func TestLoginSecurePostMigrationRemovesTokenFromConfig(t *testing.T) {
|
||||
// Given we have logged in insecurely
|
||||
authCfg := newTestAuthConfig(t)
|
||||
_, err := preMigrationLogin(authCfg, "github.com", "test-user", "test-token", "", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// When we migrate and login again with secure storage
|
||||
var m migration.MultiAccount
|
||||
c := cfg{authCfg.cfg}
|
||||
require.NoError(t, c.Migrate(m))
|
||||
|
||||
_, err = authCfg.Login("github.com", "test-user", "test-token", "", true)
|
||||
|
||||
// Then it returns success, having removed the old insecure oauth token entry
|
||||
require.NoError(t, err)
|
||||
requireNoKey(t, authCfg.cfg, []string{hostsKey, "github.com", oauthTokenKey})
|
||||
requireNoKey(t, authCfg.cfg, []string{hostsKey, "github.com", usersKey, "test-user", oauthTokenKey})
|
||||
}
|
||||
|
||||
// Copied and pasted directly from the trunk branch before doing any work on
|
||||
// login, plus the addition of AuthConfig as the first arg since it is a method
|
||||
// receiver in the real implementation.
|
||||
func preMigrationLogin(c *AuthConfig, hostname, username, token, gitProtocol string, secureStorage bool) (bool, error) {
|
||||
var setErr error
|
||||
if secureStorage {
|
||||
if setErr = keyring.Set(keyringServiceName(hostname), "", token); setErr == nil {
|
||||
// Clean up the previous oauth_token from the config file.
|
||||
_ = c.cfg.Remove([]string{hostsKey, hostname, oauthTokenKey})
|
||||
}
|
||||
}
|
||||
insecureStorageUsed := false
|
||||
if !secureStorage || setErr != nil {
|
||||
c.cfg.Set([]string{hostsKey, hostname, oauthTokenKey}, token)
|
||||
insecureStorageUsed = true
|
||||
}
|
||||
|
||||
c.cfg.Set([]string{hostsKey, hostname, userKey}, username)
|
||||
|
||||
if gitProtocol != "" {
|
||||
c.cfg.Set([]string{hostsKey, hostname, gitProtocolKey}, gitProtocol)
|
||||
}
|
||||
return insecureStorageUsed, ghConfig.Write(c.cfg)
|
||||
}
|
||||
|
|
@ -1,35 +1,36 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/keyring"
|
||||
o "github.com/cli/cli/v2/pkg/option"
|
||||
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
|
||||
ghConfig "github.com/cli/go-gh/v2/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
aliases = "aliases"
|
||||
hosts = "hosts"
|
||||
oauthToken = "oauth_token"
|
||||
aliasesKey = "aliases"
|
||||
browserKey = "browser"
|
||||
editorKey = "editor"
|
||||
gitProtocolKey = "git_protocol"
|
||||
hostsKey = "hosts"
|
||||
httpUnixSocketKey = "http_unix_socket"
|
||||
oauthTokenKey = "oauth_token"
|
||||
pagerKey = "pager"
|
||||
promptKey = "prompt"
|
||||
userKey = "user"
|
||||
usersKey = "users"
|
||||
versionKey = "version"
|
||||
)
|
||||
|
||||
// This interface describes interacting with some persistent configuration for gh.
|
||||
//
|
||||
//go:generate moq -rm -out config_mock.go . Config
|
||||
type Config interface {
|
||||
Get(string, string) (string, error)
|
||||
GetOrDefault(string, string) (string, error)
|
||||
Set(string, string, string)
|
||||
Write() error
|
||||
|
||||
Aliases() *AliasConfig
|
||||
Authentication() *AuthConfig
|
||||
}
|
||||
|
||||
func NewConfig() (Config, error) {
|
||||
c, err := ghConfig.Read()
|
||||
func NewConfig() (gh.Config, error) {
|
||||
c, err := ghConfig.Read(fallbackConfig())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -41,37 +42,44 @@ type cfg struct {
|
|||
cfg *ghConfig.Config
|
||||
}
|
||||
|
||||
func (c *cfg) Get(hostname, key string) (string, error) {
|
||||
func (c *cfg) get(hostname, key string) o.Option[string] {
|
||||
if hostname != "" {
|
||||
val, err := c.cfg.Get([]string{hosts, hostname, key})
|
||||
val, err := c.cfg.Get([]string{hostsKey, hostname, key})
|
||||
if err == nil {
|
||||
return val, err
|
||||
return o.Some(val)
|
||||
}
|
||||
}
|
||||
|
||||
return c.cfg.Get([]string{key})
|
||||
val, err := c.cfg.Get([]string{key})
|
||||
if err == nil {
|
||||
return o.Some(val)
|
||||
}
|
||||
|
||||
return o.None[string]()
|
||||
}
|
||||
|
||||
func (c *cfg) GetOrDefault(hostname, key string) (string, error) {
|
||||
var val string
|
||||
var err error
|
||||
if hostname != "" {
|
||||
val, err = c.cfg.Get([]string{hosts, hostname, key})
|
||||
if err == nil {
|
||||
return val, err
|
||||
}
|
||||
func (c *cfg) GetOrDefault(hostname, key string) o.Option[gh.ConfigEntry] {
|
||||
if val := c.get(hostname, key); val.IsSome() {
|
||||
// Map the Option[string] to Option[gh.ConfigEntry] with a source of ConfigUserProvided
|
||||
return o.Map(val, toConfigEntry(gh.ConfigUserProvided))
|
||||
}
|
||||
|
||||
val, err = c.cfg.Get([]string{key})
|
||||
if err == nil {
|
||||
return val, err
|
||||
if defaultVal := defaultFor(key); defaultVal.IsSome() {
|
||||
// Map the Option[string] to Option[gh.ConfigEntry] with a source of ConfigDefaultProvided
|
||||
return o.Map(defaultVal, toConfigEntry(gh.ConfigDefaultProvided))
|
||||
}
|
||||
|
||||
if defaultExists(key) {
|
||||
return defaultFor(key), nil
|
||||
}
|
||||
return o.None[gh.ConfigEntry]()
|
||||
}
|
||||
|
||||
return val, err
|
||||
// toConfigEntry is a helper function to convert a string value to a ConfigEntry with a given source.
|
||||
//
|
||||
// It's a bit of FP style but it allows us to map an Option[string] to Option[gh.ConfigEntry] without
|
||||
// unwrapping the it and rewrapping it.
|
||||
func toConfigEntry(source gh.ConfigSource) func(val string) gh.ConfigEntry {
|
||||
return func(val string) gh.ConfigEntry {
|
||||
return gh.ConfigEntry{Value: val, Source: source}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cfg) Set(hostname, key, value string) {
|
||||
|
|
@ -79,37 +87,100 @@ func (c *cfg) Set(hostname, key, value string) {
|
|||
c.cfg.Set([]string{key}, value)
|
||||
return
|
||||
}
|
||||
c.cfg.Set([]string{hosts, hostname, key}, value)
|
||||
|
||||
c.cfg.Set([]string{hostsKey, hostname, key}, value)
|
||||
|
||||
if user, _ := c.cfg.Get([]string{hostsKey, hostname, userKey}); user != "" {
|
||||
c.cfg.Set([]string{hostsKey, hostname, usersKey, user, key}, value)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *cfg) Write() error {
|
||||
return ghConfig.Write(c.cfg)
|
||||
}
|
||||
|
||||
func (c *cfg) Aliases() *AliasConfig {
|
||||
func (c *cfg) Aliases() gh.AliasConfig {
|
||||
return &AliasConfig{cfg: c.cfg}
|
||||
}
|
||||
|
||||
func (c *cfg) Authentication() *AuthConfig {
|
||||
func (c *cfg) Authentication() gh.AuthConfig {
|
||||
return &AuthConfig{cfg: c.cfg}
|
||||
}
|
||||
|
||||
func defaultFor(key string) string {
|
||||
for _, co := range configOptions {
|
||||
if co.Key == key {
|
||||
return co.DefaultValue
|
||||
}
|
||||
}
|
||||
return ""
|
||||
func (c *cfg) Browser(hostname string) gh.ConfigEntry {
|
||||
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
|
||||
return c.GetOrDefault(hostname, browserKey).Unwrap()
|
||||
}
|
||||
|
||||
func defaultExists(key string) bool {
|
||||
for _, co := range configOptions {
|
||||
func (c *cfg) Editor(hostname string) gh.ConfigEntry {
|
||||
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
|
||||
return c.GetOrDefault(hostname, editorKey).Unwrap()
|
||||
}
|
||||
|
||||
func (c *cfg) GitProtocol(hostname string) gh.ConfigEntry {
|
||||
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
|
||||
return c.GetOrDefault(hostname, gitProtocolKey).Unwrap()
|
||||
}
|
||||
|
||||
func (c *cfg) HTTPUnixSocket(hostname string) gh.ConfigEntry {
|
||||
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
|
||||
return c.GetOrDefault(hostname, httpUnixSocketKey).Unwrap()
|
||||
}
|
||||
|
||||
func (c *cfg) Pager(hostname string) gh.ConfigEntry {
|
||||
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
|
||||
return c.GetOrDefault(hostname, pagerKey).Unwrap()
|
||||
}
|
||||
|
||||
func (c *cfg) Prompt(hostname string) gh.ConfigEntry {
|
||||
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
|
||||
return c.GetOrDefault(hostname, promptKey).Unwrap()
|
||||
}
|
||||
|
||||
func (c *cfg) Version() o.Option[string] {
|
||||
return c.get("", versionKey)
|
||||
}
|
||||
|
||||
func (c *cfg) Migrate(m gh.Migration) error {
|
||||
// If there is no version entry we must never have applied a migration, and the following conditional logic
|
||||
// handles the version as an empty string correctly.
|
||||
version := c.Version().UnwrapOrZero()
|
||||
|
||||
// If migration has already occurred then do not attempt to migrate again.
|
||||
if m.PostVersion() == version {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If migration is incompatible with current version then return an error.
|
||||
if m.PreVersion() != version {
|
||||
return fmt.Errorf("failed to migrate as %q pre migration version did not match config version %q", m.PreVersion(), version)
|
||||
}
|
||||
|
||||
if err := m.Do(c.cfg); err != nil {
|
||||
return fmt.Errorf("failed to migrate config: %s", err)
|
||||
}
|
||||
|
||||
c.Set("", versionKey, m.PostVersion())
|
||||
|
||||
// Then write out our migrated config.
|
||||
if err := c.Write(); err != nil {
|
||||
return fmt.Errorf("failed to write config after migration: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *cfg) CacheDir() string {
|
||||
return ghConfig.CacheDir()
|
||||
}
|
||||
|
||||
func defaultFor(key string) o.Option[string] {
|
||||
for _, co := range Options {
|
||||
if co.Key == key {
|
||||
return true
|
||||
return o.Some(co.DefaultValue)
|
||||
}
|
||||
}
|
||||
return false
|
||||
return o.None[string]()
|
||||
}
|
||||
|
||||
// AuthConfig is used for interacting with some persistent configuration for gh,
|
||||
|
|
@ -122,10 +193,10 @@ type AuthConfig struct {
|
|||
tokenOverride func(string) (string, string)
|
||||
}
|
||||
|
||||
// Token will retrieve the auth token for the given hostname,
|
||||
// ActiveToken will retrieve the active auth token for the given hostname,
|
||||
// searching environment variables, plain text config, and
|
||||
// lastly encrypted storage.
|
||||
func (c *AuthConfig) Token(hostname string) (string, string) {
|
||||
func (c *AuthConfig) ActiveToken(hostname string) (string, string) {
|
||||
if c.tokenOverride != nil {
|
||||
return c.tokenOverride(hostname)
|
||||
}
|
||||
|
|
@ -153,13 +224,17 @@ func (c *AuthConfig) HasEnvToken() bool {
|
|||
return true
|
||||
}
|
||||
}
|
||||
// TODO: This is _extremely_ knowledgeable about the implementation of TokenFromEnvOrConfig
|
||||
// It has to use a hostname that is not going to be found in the hosts so that it
|
||||
// can guarantee that tokens will only be returned from a set env var.
|
||||
// Discussed here, but maybe worth revisiting: https://github.com/cli/cli/pull/7169#discussion_r1136979033
|
||||
token, _ := ghAuth.TokenFromEnvOrConfig(hostname)
|
||||
return token != ""
|
||||
}
|
||||
|
||||
// SetToken will override any token resolution and return the given
|
||||
// token and source for all calls to Token. Use for testing purposes only.
|
||||
func (c *AuthConfig) SetToken(token, source string) {
|
||||
// SetActiveToken will override any token resolution and return the given
|
||||
// token and source for all calls to ActiveToken. Use for testing purposes only.
|
||||
func (c *AuthConfig) SetActiveToken(token, source string) {
|
||||
c.tokenOverride = func(_ string) (string, string) {
|
||||
return token, source
|
||||
}
|
||||
|
|
@ -171,20 +246,24 @@ func (c *AuthConfig) TokenFromKeyring(hostname string) (string, error) {
|
|||
return keyring.Get(keyringServiceName(hostname), "")
|
||||
}
|
||||
|
||||
// User will retrieve the username for the logged in user at the given hostname.
|
||||
func (c *AuthConfig) User(hostname string) (string, error) {
|
||||
return c.cfg.Get([]string{hosts, hostname, "user"})
|
||||
// TokenFromKeyringForUser will retrieve the auth token for the given hostname
|
||||
// and username, only searching in encrypted storage.
|
||||
//
|
||||
// An empty username will return an error because the potential to return
|
||||
// the currently active token under surprising cases is just too high to risk
|
||||
// compared to the utility of having the function being smart.
|
||||
func (c *AuthConfig) TokenFromKeyringForUser(hostname, username string) (string, error) {
|
||||
if username == "" {
|
||||
return "", errors.New("username cannot be blank")
|
||||
}
|
||||
|
||||
return keyring.Get(keyringServiceName(hostname), username)
|
||||
}
|
||||
|
||||
// GitProtocol will retrieve the git protocol for the logged in user at the given hostname.
|
||||
// If none is set it will return the default value.
|
||||
func (c *AuthConfig) GitProtocol(hostname string) (string, error) {
|
||||
key := "git_protocol"
|
||||
val, err := c.cfg.Get([]string{hosts, hostname, key})
|
||||
if err == nil {
|
||||
return val, err
|
||||
}
|
||||
return defaultFor(key), nil
|
||||
// ActiveUser will retrieve the username for the active user at the given hostname.
|
||||
// This will not be accurate if the oauth token is set from an environment variable.
|
||||
func (c *AuthConfig) ActiveUser(hostname string) (string, error) {
|
||||
return c.cfg.Get([]string{hostsKey, hostname, userKey})
|
||||
}
|
||||
|
||||
func (c *AuthConfig) Hosts() []string {
|
||||
|
|
@ -220,35 +299,162 @@ func (c *AuthConfig) SetDefaultHost(host, source string) {
|
|||
// Login will set user, git protocol, and auth token for the given hostname.
|
||||
// If the encrypt option is specified it will first try to store the auth token
|
||||
// in encrypted storage and will fall back to the plain text config file.
|
||||
func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secureStorage bool) error {
|
||||
func (c *AuthConfig) Login(hostname, username, token, gitProtocol string, secureStorage bool) (bool, error) {
|
||||
// In this section we set up the users config
|
||||
var setErr error
|
||||
if secureStorage {
|
||||
if setErr = keyring.Set(keyringServiceName(hostname), "", token); setErr == nil {
|
||||
// Clean up the previous oauth_token from the config file.
|
||||
_ = c.cfg.Remove([]string{hosts, hostname, oauthToken})
|
||||
// Try to set the token for this user in the encrypted storage for later switching
|
||||
setErr = keyring.Set(keyringServiceName(hostname), username, token)
|
||||
if setErr == nil {
|
||||
// Clean up the previous oauth_token from the config file, if there were one
|
||||
_ = c.cfg.Remove([]string{hostsKey, hostname, usersKey, username, oauthTokenKey})
|
||||
}
|
||||
}
|
||||
insecureStorageUsed := false
|
||||
if !secureStorage || setErr != nil {
|
||||
c.cfg.Set([]string{hosts, hostname, oauthToken}, token)
|
||||
}
|
||||
if username != "" {
|
||||
c.cfg.Set([]string{hosts, hostname, "user"}, username)
|
||||
// And set the oauth token under the user for later switching
|
||||
c.cfg.Set([]string{hostsKey, hostname, usersKey, username, oauthTokenKey}, token)
|
||||
insecureStorageUsed = true
|
||||
}
|
||||
|
||||
if gitProtocol != "" {
|
||||
c.cfg.Set([]string{hosts, hostname, "git_protocol"}, gitProtocol)
|
||||
// Set the host level git protocol
|
||||
// Although it might be expected that this is handled by switch, git protocol
|
||||
// is currently a host level config and not a user level config, so any change
|
||||
// will overwrite the protocol for all users on the host.
|
||||
c.cfg.Set([]string{hostsKey, hostname, gitProtocolKey}, gitProtocol)
|
||||
}
|
||||
return ghConfig.Write(c.cfg)
|
||||
|
||||
// Create the username key with an empty value so it will be
|
||||
// written even when there are no keys set under it.
|
||||
if _, getErr := c.cfg.Get([]string{hostsKey, hostname, usersKey, username}); getErr != nil {
|
||||
c.cfg.Set([]string{hostsKey, hostname, usersKey, username}, "")
|
||||
}
|
||||
|
||||
// Then we activate the new user
|
||||
return insecureStorageUsed, c.activateUser(hostname, username)
|
||||
}
|
||||
|
||||
func (c *AuthConfig) SwitchUser(hostname, user string) error {
|
||||
previouslyActiveUser, err := c.ActiveUser(hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get active user: %s", err)
|
||||
}
|
||||
|
||||
previouslyActiveToken, previousSource := c.ActiveToken(hostname)
|
||||
if previousSource != "keyring" && previousSource != "oauth_token" {
|
||||
return fmt.Errorf("currently active token for %s is from %s", hostname, previousSource)
|
||||
}
|
||||
|
||||
err = c.activateUser(hostname, user)
|
||||
if err != nil {
|
||||
// Given that activateUser can only fail before the config is written, or when writing the config
|
||||
// we know for sure that the config has not been written. However, we still should restore it back
|
||||
// to its previous clean state just in case something else tries to make use of the config, or tries
|
||||
// to write it again.
|
||||
if previousSource == "keyring" {
|
||||
if setErr := keyring.Set(keyringServiceName(hostname), "", previouslyActiveToken); setErr != nil {
|
||||
err = errors.Join(err, setErr)
|
||||
}
|
||||
}
|
||||
|
||||
if previousSource == "oauth_token" {
|
||||
c.cfg.Set([]string{hostsKey, hostname, oauthTokenKey}, previouslyActiveToken)
|
||||
}
|
||||
c.cfg.Set([]string{hostsKey, hostname, userKey}, previouslyActiveUser)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Logout will remove user, git protocol, and auth token for the given hostname.
|
||||
// It will remove the auth token from the encrypted storage if it exists there.
|
||||
func (c *AuthConfig) Logout(hostname string) error {
|
||||
if hostname == "" {
|
||||
func (c *AuthConfig) Logout(hostname, username string) error {
|
||||
users := c.UsersForHost(hostname)
|
||||
|
||||
// If there is only one (or zero) users, then we remove the host
|
||||
// and unset the keyring tokens.
|
||||
if len(users) < 2 {
|
||||
_ = c.cfg.Remove([]string{hostsKey, hostname})
|
||||
_ = keyring.Delete(keyringServiceName(hostname), "")
|
||||
_ = keyring.Delete(keyringServiceName(hostname), username)
|
||||
return ghConfig.Write(c.cfg)
|
||||
}
|
||||
|
||||
// Otherwise, we remove the user from this host
|
||||
_ = c.cfg.Remove([]string{hostsKey, hostname, usersKey, username})
|
||||
|
||||
// This error is ignorable because we already know there is an active user for the host
|
||||
activeUser, _ := c.ActiveUser(hostname)
|
||||
|
||||
// If the user we're removing isn't active, then we just write the config
|
||||
if activeUser != username {
|
||||
return ghConfig.Write(c.cfg)
|
||||
}
|
||||
|
||||
// Otherwise we get the first user in the slice that isn't the user we're removing
|
||||
switchUserIdx := slices.IndexFunc(users, func(n string) bool {
|
||||
return n != username
|
||||
})
|
||||
|
||||
// And activate them
|
||||
return c.activateUser(hostname, users[switchUserIdx])
|
||||
}
|
||||
|
||||
func (c *AuthConfig) activateUser(hostname, user string) error {
|
||||
// We first need to idempotently clear out any set tokens for the host
|
||||
_ = keyring.Delete(keyringServiceName(hostname), "")
|
||||
_ = c.cfg.Remove([]string{hostsKey, hostname, oauthTokenKey})
|
||||
|
||||
// Then we'll move the keyring token or insecure token as necessary, only one of the
|
||||
// following branches should be true.
|
||||
|
||||
// If there is a token in the secure keyring for the user, move it to the active slot
|
||||
var tokenSwitched bool
|
||||
if token, err := keyring.Get(keyringServiceName(hostname), user); err == nil {
|
||||
if err = keyring.Set(keyringServiceName(hostname), "", token); err != nil {
|
||||
return fmt.Errorf("failed to move active token in keyring: %v", err)
|
||||
}
|
||||
tokenSwitched = true
|
||||
}
|
||||
|
||||
// If there is a token in the insecure config for the user, move it to the active field
|
||||
if token, err := c.cfg.Get([]string{hostsKey, hostname, usersKey, user, oauthTokenKey}); err == nil {
|
||||
c.cfg.Set([]string{hostsKey, hostname, oauthTokenKey}, token)
|
||||
tokenSwitched = true
|
||||
}
|
||||
|
||||
if !tokenSwitched {
|
||||
return fmt.Errorf("no token found for %s", user)
|
||||
}
|
||||
|
||||
// Then we'll update the active user for the host
|
||||
c.cfg.Set([]string{hostsKey, hostname, userKey}, user)
|
||||
|
||||
return ghConfig.Write(c.cfg)
|
||||
}
|
||||
|
||||
func (c *AuthConfig) UsersForHost(hostname string) []string {
|
||||
users, err := c.cfg.Keys([]string{hostsKey, hostname, usersKey})
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
_ = c.cfg.Remove([]string{hosts, hostname})
|
||||
_ = keyring.Delete(keyringServiceName(hostname), "")
|
||||
return ghConfig.Write(c.cfg)
|
||||
|
||||
return users
|
||||
}
|
||||
|
||||
func (c *AuthConfig) TokenForUser(hostname, user string) (string, string, error) {
|
||||
if token, err := keyring.Get(keyringServiceName(hostname), user); err == nil {
|
||||
return token, "keyring", nil
|
||||
}
|
||||
|
||||
if token, err := c.cfg.Get([]string{hostsKey, hostname, usersKey, user, oauthTokenKey}); err == nil {
|
||||
return token, "oauth_token", nil
|
||||
}
|
||||
|
||||
return "", "default", fmt.Errorf("no token found for '%s'", user)
|
||||
}
|
||||
|
||||
func keyringServiceName(hostname string) string {
|
||||
|
|
@ -260,76 +466,121 @@ type AliasConfig struct {
|
|||
}
|
||||
|
||||
func (a *AliasConfig) Get(alias string) (string, error) {
|
||||
return a.cfg.Get([]string{aliases, alias})
|
||||
return a.cfg.Get([]string{aliasesKey, alias})
|
||||
}
|
||||
|
||||
func (a *AliasConfig) Add(alias, expansion string) {
|
||||
a.cfg.Set([]string{aliases, alias}, expansion)
|
||||
a.cfg.Set([]string{aliasesKey, alias}, expansion)
|
||||
}
|
||||
|
||||
func (a *AliasConfig) Delete(alias string) error {
|
||||
return a.cfg.Remove([]string{aliases, alias})
|
||||
return a.cfg.Remove([]string{aliasesKey, alias})
|
||||
}
|
||||
|
||||
func (a *AliasConfig) All() map[string]string {
|
||||
out := map[string]string{}
|
||||
keys, err := a.cfg.Keys([]string{aliases})
|
||||
keys, err := a.cfg.Keys([]string{aliasesKey})
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, key := range keys {
|
||||
val, _ := a.cfg.Get([]string{aliases, key})
|
||||
val, _ := a.cfg.Get([]string{aliasesKey, key})
|
||||
out[key] = val
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fallbackConfig() *ghConfig.Config {
|
||||
return ghConfig.ReadFromString(defaultConfigStr)
|
||||
}
|
||||
|
||||
// The schema version in here should match the PostVersion of whatever the
|
||||
// last migration we decided to run is. Therefore, if we run a new migration,
|
||||
// this should be bumped.
|
||||
const defaultConfigStr = `
|
||||
# The default config file, auto-generated by gh. Run 'gh environment' to learn more about
|
||||
# environment variables respected by gh and their precedence.
|
||||
|
||||
# The current version of the config schema
|
||||
version: 1
|
||||
# What protocol to use when performing git operations. Supported values: ssh, https
|
||||
git_protocol: https
|
||||
# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.
|
||||
editor:
|
||||
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
||||
prompt: enabled
|
||||
# A pager program to send command output to, e.g. "less". If blank, will refer to environment. Set the value to "cat" to disable the pager.
|
||||
pager:
|
||||
# Aliases allow you to create nicknames for gh commands
|
||||
aliases:
|
||||
co: pr checkout
|
||||
# The path to a unix socket through which send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.
|
||||
http_unix_socket:
|
||||
# What web browser gh should use when opening URLs. If blank, will refer to environment.
|
||||
browser:
|
||||
`
|
||||
|
||||
type ConfigOption struct {
|
||||
Key string
|
||||
Description string
|
||||
DefaultValue string
|
||||
AllowedValues []string
|
||||
CurrentValue func(c gh.Config, hostname string) string
|
||||
}
|
||||
|
||||
var configOptions = []ConfigOption{
|
||||
var Options = []ConfigOption{
|
||||
{
|
||||
Key: "git_protocol",
|
||||
Key: gitProtocolKey,
|
||||
Description: "the protocol to use for git clone and push operations",
|
||||
DefaultValue: "https",
|
||||
AllowedValues: []string{"https", "ssh"},
|
||||
CurrentValue: func(c gh.Config, hostname string) string {
|
||||
return c.GitProtocol(hostname).Value
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "editor",
|
||||
Key: editorKey,
|
||||
Description: "the text editor program to use for authoring text",
|
||||
DefaultValue: "",
|
||||
CurrentValue: func(c gh.Config, hostname string) string {
|
||||
return c.Editor(hostname).Value
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "prompt",
|
||||
Key: promptKey,
|
||||
Description: "toggle interactive prompting in the terminal",
|
||||
DefaultValue: "enabled",
|
||||
AllowedValues: []string{"enabled", "disabled"},
|
||||
CurrentValue: func(c gh.Config, hostname string) string {
|
||||
return c.Prompt(hostname).Value
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "pager",
|
||||
Key: pagerKey,
|
||||
Description: "the terminal pager program to send standard output to",
|
||||
DefaultValue: "",
|
||||
CurrentValue: func(c gh.Config, hostname string) string {
|
||||
return c.Pager(hostname).Value
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "http_unix_socket",
|
||||
Key: httpUnixSocketKey,
|
||||
Description: "the path to a Unix socket through which to make an HTTP connection",
|
||||
DefaultValue: "",
|
||||
CurrentValue: func(c gh.Config, hostname string) string {
|
||||
return c.HTTPUnixSocket(hostname).Value
|
||||
},
|
||||
},
|
||||
{
|
||||
Key: "browser",
|
||||
Key: browserKey,
|
||||
Description: "the web browser to use for opening URLs",
|
||||
DefaultValue: "",
|
||||
CurrentValue: func(c gh.Config, hostname string) string {
|
||||
return c.Browser(hostname).Value
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func ConfigOptions() []ConfigOption {
|
||||
return configOptions
|
||||
}
|
||||
|
||||
func HomeDirPath(subdir string) (string, error) {
|
||||
homeDir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -1,297 +0,0 @@
|
|||
// Code generated by moq; DO NOT EDIT.
|
||||
// github.com/matryer/moq
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Ensure, that ConfigMock does implement Config.
|
||||
// If this is not the case, regenerate this file with moq.
|
||||
var _ Config = &ConfigMock{}
|
||||
|
||||
// ConfigMock is a mock implementation of Config.
|
||||
//
|
||||
// func TestSomethingThatUsesConfig(t *testing.T) {
|
||||
//
|
||||
// // make and configure a mocked Config
|
||||
// mockedConfig := &ConfigMock{
|
||||
// AliasesFunc: func() *AliasConfig {
|
||||
// panic("mock out the Aliases method")
|
||||
// },
|
||||
// AuthenticationFunc: func() *AuthConfig {
|
||||
// panic("mock out the Authentication method")
|
||||
// },
|
||||
// GetFunc: func(s1 string, s2 string) (string, error) {
|
||||
// panic("mock out the Get method")
|
||||
// },
|
||||
// GetOrDefaultFunc: func(s1 string, s2 string) (string, error) {
|
||||
// panic("mock out the GetOrDefault method")
|
||||
// },
|
||||
// SetFunc: func(s1 string, s2 string, s3 string) {
|
||||
// panic("mock out the Set method")
|
||||
// },
|
||||
// WriteFunc: func() error {
|
||||
// panic("mock out the Write method")
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// // use mockedConfig in code that requires Config
|
||||
// // and then make assertions.
|
||||
//
|
||||
// }
|
||||
type ConfigMock struct {
|
||||
// AliasesFunc mocks the Aliases method.
|
||||
AliasesFunc func() *AliasConfig
|
||||
|
||||
// AuthenticationFunc mocks the Authentication method.
|
||||
AuthenticationFunc func() *AuthConfig
|
||||
|
||||
// GetFunc mocks the Get method.
|
||||
GetFunc func(s1 string, s2 string) (string, error)
|
||||
|
||||
// GetOrDefaultFunc mocks the GetOrDefault method.
|
||||
GetOrDefaultFunc func(s1 string, s2 string) (string, error)
|
||||
|
||||
// SetFunc mocks the Set method.
|
||||
SetFunc func(s1 string, s2 string, s3 string)
|
||||
|
||||
// WriteFunc mocks the Write method.
|
||||
WriteFunc func() error
|
||||
|
||||
// calls tracks calls to the methods.
|
||||
calls struct {
|
||||
// Aliases holds details about calls to the Aliases method.
|
||||
Aliases []struct {
|
||||
}
|
||||
// Authentication holds details about calls to the Authentication method.
|
||||
Authentication []struct {
|
||||
}
|
||||
// Get holds details about calls to the Get method.
|
||||
Get []struct {
|
||||
// S1 is the s1 argument value.
|
||||
S1 string
|
||||
// S2 is the s2 argument value.
|
||||
S2 string
|
||||
}
|
||||
// GetOrDefault holds details about calls to the GetOrDefault method.
|
||||
GetOrDefault []struct {
|
||||
// S1 is the s1 argument value.
|
||||
S1 string
|
||||
// S2 is the s2 argument value.
|
||||
S2 string
|
||||
}
|
||||
// Set holds details about calls to the Set method.
|
||||
Set []struct {
|
||||
// S1 is the s1 argument value.
|
||||
S1 string
|
||||
// S2 is the s2 argument value.
|
||||
S2 string
|
||||
// S3 is the s3 argument value.
|
||||
S3 string
|
||||
}
|
||||
// Write holds details about calls to the Write method.
|
||||
Write []struct {
|
||||
}
|
||||
}
|
||||
lockAliases sync.RWMutex
|
||||
lockAuthentication sync.RWMutex
|
||||
lockGet sync.RWMutex
|
||||
lockGetOrDefault sync.RWMutex
|
||||
lockSet sync.RWMutex
|
||||
lockWrite sync.RWMutex
|
||||
}
|
||||
|
||||
// Aliases calls AliasesFunc.
|
||||
func (mock *ConfigMock) Aliases() *AliasConfig {
|
||||
if mock.AliasesFunc == nil {
|
||||
panic("ConfigMock.AliasesFunc: method is nil but Config.Aliases was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
}{}
|
||||
mock.lockAliases.Lock()
|
||||
mock.calls.Aliases = append(mock.calls.Aliases, callInfo)
|
||||
mock.lockAliases.Unlock()
|
||||
return mock.AliasesFunc()
|
||||
}
|
||||
|
||||
// AliasesCalls gets all the calls that were made to Aliases.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.AliasesCalls())
|
||||
func (mock *ConfigMock) AliasesCalls() []struct {
|
||||
} {
|
||||
var calls []struct {
|
||||
}
|
||||
mock.lockAliases.RLock()
|
||||
calls = mock.calls.Aliases
|
||||
mock.lockAliases.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Authentication calls AuthenticationFunc.
|
||||
func (mock *ConfigMock) Authentication() *AuthConfig {
|
||||
if mock.AuthenticationFunc == nil {
|
||||
panic("ConfigMock.AuthenticationFunc: method is nil but Config.Authentication was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
}{}
|
||||
mock.lockAuthentication.Lock()
|
||||
mock.calls.Authentication = append(mock.calls.Authentication, callInfo)
|
||||
mock.lockAuthentication.Unlock()
|
||||
return mock.AuthenticationFunc()
|
||||
}
|
||||
|
||||
// AuthenticationCalls gets all the calls that were made to Authentication.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.AuthenticationCalls())
|
||||
func (mock *ConfigMock) AuthenticationCalls() []struct {
|
||||
} {
|
||||
var calls []struct {
|
||||
}
|
||||
mock.lockAuthentication.RLock()
|
||||
calls = mock.calls.Authentication
|
||||
mock.lockAuthentication.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Get calls GetFunc.
|
||||
func (mock *ConfigMock) Get(s1 string, s2 string) (string, error) {
|
||||
if mock.GetFunc == nil {
|
||||
panic("ConfigMock.GetFunc: method is nil but Config.Get was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
S1 string
|
||||
S2 string
|
||||
}{
|
||||
S1: s1,
|
||||
S2: s2,
|
||||
}
|
||||
mock.lockGet.Lock()
|
||||
mock.calls.Get = append(mock.calls.Get, callInfo)
|
||||
mock.lockGet.Unlock()
|
||||
return mock.GetFunc(s1, s2)
|
||||
}
|
||||
|
||||
// GetCalls gets all the calls that were made to Get.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.GetCalls())
|
||||
func (mock *ConfigMock) GetCalls() []struct {
|
||||
S1 string
|
||||
S2 string
|
||||
} {
|
||||
var calls []struct {
|
||||
S1 string
|
||||
S2 string
|
||||
}
|
||||
mock.lockGet.RLock()
|
||||
calls = mock.calls.Get
|
||||
mock.lockGet.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// GetOrDefault calls GetOrDefaultFunc.
|
||||
func (mock *ConfigMock) GetOrDefault(s1 string, s2 string) (string, error) {
|
||||
if mock.GetOrDefaultFunc == nil {
|
||||
panic("ConfigMock.GetOrDefaultFunc: method is nil but Config.GetOrDefault was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
S1 string
|
||||
S2 string
|
||||
}{
|
||||
S1: s1,
|
||||
S2: s2,
|
||||
}
|
||||
mock.lockGetOrDefault.Lock()
|
||||
mock.calls.GetOrDefault = append(mock.calls.GetOrDefault, callInfo)
|
||||
mock.lockGetOrDefault.Unlock()
|
||||
return mock.GetOrDefaultFunc(s1, s2)
|
||||
}
|
||||
|
||||
// GetOrDefaultCalls gets all the calls that were made to GetOrDefault.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.GetOrDefaultCalls())
|
||||
func (mock *ConfigMock) GetOrDefaultCalls() []struct {
|
||||
S1 string
|
||||
S2 string
|
||||
} {
|
||||
var calls []struct {
|
||||
S1 string
|
||||
S2 string
|
||||
}
|
||||
mock.lockGetOrDefault.RLock()
|
||||
calls = mock.calls.GetOrDefault
|
||||
mock.lockGetOrDefault.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Set calls SetFunc.
|
||||
func (mock *ConfigMock) Set(s1 string, s2 string, s3 string) {
|
||||
if mock.SetFunc == nil {
|
||||
panic("ConfigMock.SetFunc: method is nil but Config.Set was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
S1 string
|
||||
S2 string
|
||||
S3 string
|
||||
}{
|
||||
S1: s1,
|
||||
S2: s2,
|
||||
S3: s3,
|
||||
}
|
||||
mock.lockSet.Lock()
|
||||
mock.calls.Set = append(mock.calls.Set, callInfo)
|
||||
mock.lockSet.Unlock()
|
||||
mock.SetFunc(s1, s2, s3)
|
||||
}
|
||||
|
||||
// SetCalls gets all the calls that were made to Set.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.SetCalls())
|
||||
func (mock *ConfigMock) SetCalls() []struct {
|
||||
S1 string
|
||||
S2 string
|
||||
S3 string
|
||||
} {
|
||||
var calls []struct {
|
||||
S1 string
|
||||
S2 string
|
||||
S3 string
|
||||
}
|
||||
mock.lockSet.RLock()
|
||||
calls = mock.calls.Set
|
||||
mock.lockSet.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Write calls WriteFunc.
|
||||
func (mock *ConfigMock) Write() error {
|
||||
if mock.WriteFunc == nil {
|
||||
panic("ConfigMock.WriteFunc: method is nil but Config.Write was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
}{}
|
||||
mock.lockWrite.Lock()
|
||||
mock.calls.Write = append(mock.calls.Write, callInfo)
|
||||
mock.lockWrite.Unlock()
|
||||
return mock.WriteFunc()
|
||||
}
|
||||
|
||||
// WriteCalls gets all the calls that were made to Write.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.WriteCalls())
|
||||
func (mock *ConfigMock) WriteCalls() []struct {
|
||||
} {
|
||||
var calls []struct {
|
||||
}
|
||||
mock.lockWrite.RLock()
|
||||
calls = mock.calls.Write
|
||||
mock.lockWrite.RUnlock()
|
||||
return calls
|
||||
}
|
||||
182
internal/config/config_test.go
Normal file
182
internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
ghConfig "github.com/cli/go-gh/v2/pkg/config"
|
||||
)
|
||||
|
||||
func newTestConfig() *cfg {
|
||||
return &cfg{
|
||||
cfg: ghConfig.ReadFromString(""),
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConfigProvidesFallback(t *testing.T) {
|
||||
var spiedCfg *ghConfig.Config
|
||||
ghConfig.Read = func(fallback *ghConfig.Config) (*ghConfig.Config, error) {
|
||||
spiedCfg = fallback
|
||||
return fallback, nil
|
||||
}
|
||||
_, err := NewConfig()
|
||||
require.NoError(t, err)
|
||||
requireKeyWithValue(t, spiedCfg, []string{versionKey}, "1")
|
||||
requireKeyWithValue(t, spiedCfg, []string{gitProtocolKey}, "https")
|
||||
requireKeyWithValue(t, spiedCfg, []string{editorKey}, "")
|
||||
requireKeyWithValue(t, spiedCfg, []string{promptKey}, "enabled")
|
||||
requireKeyWithValue(t, spiedCfg, []string{pagerKey}, "")
|
||||
requireKeyWithValue(t, spiedCfg, []string{aliasesKey, "co"}, "pr checkout")
|
||||
requireKeyWithValue(t, spiedCfg, []string{httpUnixSocketKey}, "")
|
||||
requireKeyWithValue(t, spiedCfg, []string{browserKey}, "")
|
||||
}
|
||||
|
||||
func TestGetOrDefaultApplicationDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
key string
|
||||
expectedDefault string
|
||||
}{
|
||||
{gitProtocolKey, "https"},
|
||||
{editorKey, ""},
|
||||
{promptKey, "enabled"},
|
||||
{pagerKey, ""},
|
||||
{httpUnixSocketKey, ""},
|
||||
{browserKey, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.key, func(t *testing.T) {
|
||||
// Given we have no top level configuration
|
||||
cfg := newTestConfig()
|
||||
|
||||
// When we get a key that has no value, but has a default
|
||||
optionalEntry := cfg.GetOrDefault("", tt.key)
|
||||
|
||||
// Then there is an entry with the default value, and source set as default
|
||||
entry := optionalEntry.Expect(fmt.Sprintf("expected there to be a value for %s", tt.key))
|
||||
require.Equal(t, tt.expectedDefault, entry.Value)
|
||||
require.Equal(t, gh.ConfigDefaultProvided, entry.Source)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrDefaultNonExistentKey(t *testing.T) {
|
||||
// Given we have no top level configuration
|
||||
cfg := newTestConfig()
|
||||
|
||||
// When we get a key that has no value
|
||||
optionalEntry := cfg.GetOrDefault("", "non-existent-key")
|
||||
|
||||
// Then it returns a None variant
|
||||
require.True(t, optionalEntry.IsNone(), "expected there to be no value")
|
||||
}
|
||||
|
||||
func TestGetOrDefaultNonExistentHostSpecificKey(t *testing.T) {
|
||||
// Given have no top level configuration
|
||||
cfg := newTestConfig()
|
||||
|
||||
// When we get a key for a host that has no value
|
||||
optionalEntry := cfg.GetOrDefault("non-existent-host", "non-existent-key")
|
||||
|
||||
// Then it returns a None variant
|
||||
require.True(t, optionalEntry.IsNone(), "expected there to be no value")
|
||||
}
|
||||
|
||||
func TestGetOrDefaultExistingTopLevelKey(t *testing.T) {
|
||||
// Given have a top level config entry
|
||||
cfg := newTestConfig()
|
||||
cfg.Set("", "top-level-key", "top-level-value")
|
||||
|
||||
// When we get that key
|
||||
optionalEntry := cfg.GetOrDefault("non-existent-host", "top-level-key")
|
||||
|
||||
// Then it returns a Some variant containing the correct value and a source of user
|
||||
entry := optionalEntry.Expect("expected there to be a value")
|
||||
require.Equal(t, "top-level-value", entry.Value)
|
||||
require.Equal(t, gh.ConfigUserProvided, entry.Source)
|
||||
}
|
||||
|
||||
func TestGetOrDefaultExistingHostSpecificKey(t *testing.T) {
|
||||
// Given have a host specific config entry
|
||||
cfg := newTestConfig()
|
||||
cfg.Set("github.com", "host-specific-key", "host-specific-value")
|
||||
|
||||
// When we get that key
|
||||
optionalEntry := cfg.GetOrDefault("github.com", "host-specific-key")
|
||||
|
||||
// Then it returns a Some variant containing the correct value and a source of user
|
||||
entry := optionalEntry.Expect("expected there to be a value")
|
||||
require.Equal(t, "host-specific-value", entry.Value)
|
||||
require.Equal(t, gh.ConfigUserProvided, entry.Source)
|
||||
}
|
||||
|
||||
func TestGetOrDefaultHostnameSpecificKeyFallsBackToTopLevel(t *testing.T) {
|
||||
// Given have a top level config entry
|
||||
cfg := newTestConfig()
|
||||
cfg.Set("", "key", "value")
|
||||
|
||||
// When we get that key on a specific host
|
||||
optionalEntry := cfg.GetOrDefault("github.com", "key")
|
||||
|
||||
// Then it returns a Some variant containing the correct value by falling back
|
||||
// to the top level config, with a source of user
|
||||
entry := optionalEntry.Expect("expected there to be a value")
|
||||
require.Equal(t, "value", entry.Value)
|
||||
require.Equal(t, gh.ConfigUserProvided, entry.Source)
|
||||
}
|
||||
|
||||
func TestFallbackConfig(t *testing.T) {
|
||||
cfg := fallbackConfig()
|
||||
requireKeyWithValue(t, cfg, []string{gitProtocolKey}, "https")
|
||||
requireKeyWithValue(t, cfg, []string{editorKey}, "")
|
||||
requireKeyWithValue(t, cfg, []string{promptKey}, "enabled")
|
||||
requireKeyWithValue(t, cfg, []string{pagerKey}, "")
|
||||
requireKeyWithValue(t, cfg, []string{aliasesKey, "co"}, "pr checkout")
|
||||
requireKeyWithValue(t, cfg, []string{httpUnixSocketKey}, "")
|
||||
requireKeyWithValue(t, cfg, []string{browserKey}, "")
|
||||
requireNoKey(t, cfg, []string{"unknown"})
|
||||
}
|
||||
|
||||
func TestSetTopLevelKey(t *testing.T) {
|
||||
c := newTestConfig()
|
||||
host := ""
|
||||
key := "top-level-key"
|
||||
val := "top-level-value"
|
||||
c.Set(host, key, val)
|
||||
requireKeyWithValue(t, c.cfg, []string{key}, val)
|
||||
}
|
||||
|
||||
func TestSetHostSpecificKey(t *testing.T) {
|
||||
c := newTestConfig()
|
||||
host := "github.com"
|
||||
key := "host-level-key"
|
||||
val := "host-level-value"
|
||||
c.Set(host, key, val)
|
||||
requireKeyWithValue(t, c.cfg, []string{hostsKey, host, key}, val)
|
||||
}
|
||||
|
||||
func TestSetUserSpecificKey(t *testing.T) {
|
||||
c := newTestConfig()
|
||||
host := "github.com"
|
||||
user := "test-user"
|
||||
c.cfg.Set([]string{hostsKey, host, userKey}, user)
|
||||
|
||||
key := "host-level-key"
|
||||
val := "host-level-value"
|
||||
c.Set(host, key, val)
|
||||
requireKeyWithValue(t, c.cfg, []string{hostsKey, host, key}, val)
|
||||
requireKeyWithValue(t, c.cfg, []string{hostsKey, host, usersKey, user, key}, val)
|
||||
}
|
||||
|
||||
func TestSetUserSpecificKeyNoUserPresent(t *testing.T) {
|
||||
c := newTestConfig()
|
||||
host := "github.com"
|
||||
key := "host-level-key"
|
||||
val := "host-level-value"
|
||||
c.Set(host, key, val)
|
||||
requireKeyWithValue(t, c.cfg, []string{hostsKey, host, key}, val)
|
||||
requireNoKey(t, c.cfg, []string{hostsKey, host, usersKey})
|
||||
}
|
||||
262
internal/config/migrate_test.go
Normal file
262
internal/config/migrate_test.go
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
ghmock "github.com/cli/cli/v2/internal/gh/mock"
|
||||
ghConfig "github.com/cli/go-gh/v2/pkg/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMigrationAppliedSuccessfully(t *testing.T) {
|
||||
readConfig := StubWriteConfig(t)
|
||||
|
||||
// Given we have a migrator that writes some keys to the top level config
|
||||
// and hosts key
|
||||
c := ghConfig.ReadFromString(testFullConfig())
|
||||
|
||||
topLevelKey := []string{"toplevelkey"}
|
||||
newHostKey := []string{hostsKey, "newhost"}
|
||||
|
||||
migration := mockMigration(func(config *ghConfig.Config) error {
|
||||
config.Set(topLevelKey, "toplevelvalue")
|
||||
config.Set(newHostKey, "newhostvalue")
|
||||
return nil
|
||||
})
|
||||
|
||||
// When we run the migration
|
||||
conf := cfg{c}
|
||||
require.NoError(t, conf.Migrate(migration))
|
||||
|
||||
// Then our original config is updated with the migration applied
|
||||
requireKeyWithValue(t, c, topLevelKey, "toplevelvalue")
|
||||
requireKeyWithValue(t, c, newHostKey, "newhostvalue")
|
||||
|
||||
// And our config / hosts changes are persisted to their relevant files
|
||||
// Note that this is real janky. We have writers that represent the
|
||||
// top level config and the hosts key but we don't merge them back together
|
||||
// so when we look into the hosts data, we don't nest the key we're
|
||||
// looking for under the hosts key ¯\_(ツ)_/¯
|
||||
var configBuf bytes.Buffer
|
||||
var hostsBuf bytes.Buffer
|
||||
readConfig(&configBuf, &hostsBuf)
|
||||
persistedCfg := ghConfig.ReadFromString(configBuf.String())
|
||||
persistedHosts := ghConfig.ReadFromString(hostsBuf.String())
|
||||
|
||||
requireKeyWithValue(t, persistedCfg, topLevelKey, "toplevelvalue")
|
||||
requireKeyWithValue(t, persistedHosts, []string{"newhost"}, "newhostvalue")
|
||||
}
|
||||
|
||||
func TestMigrationAppliedBumpsVersion(t *testing.T) {
|
||||
readConfig := StubWriteConfig(t)
|
||||
|
||||
// Given we have a migration with a pre version that matches
|
||||
// the version in the config
|
||||
c := ghConfig.ReadFromString(testFullConfig())
|
||||
c.Set([]string{versionKey}, "expected-pre-version")
|
||||
topLevelKey := []string{"toplevelkey"}
|
||||
|
||||
migration := &ghmock.MigrationMock{
|
||||
DoFunc: func(config *ghConfig.Config) error {
|
||||
config.Set(topLevelKey, "toplevelvalue")
|
||||
return nil
|
||||
},
|
||||
PreVersionFunc: func() string {
|
||||
return "expected-pre-version"
|
||||
},
|
||||
PostVersionFunc: func() string {
|
||||
return "expected-post-version"
|
||||
},
|
||||
}
|
||||
|
||||
// When we migrate
|
||||
conf := cfg{c}
|
||||
require.NoError(t, conf.Migrate(migration))
|
||||
|
||||
// Then our original config is updated with the migration applied
|
||||
requireKeyWithValue(t, c, topLevelKey, "toplevelvalue")
|
||||
requireKeyWithValue(t, c, []string{versionKey}, "expected-post-version")
|
||||
|
||||
// And our config / hosts changes are persisted to their relevant files
|
||||
var configBuf bytes.Buffer
|
||||
readConfig(&configBuf, io.Discard)
|
||||
persistedCfg := ghConfig.ReadFromString(configBuf.String())
|
||||
|
||||
requireKeyWithValue(t, persistedCfg, topLevelKey, "toplevelvalue")
|
||||
requireKeyWithValue(t, persistedCfg, []string{versionKey}, "expected-post-version")
|
||||
}
|
||||
|
||||
func TestMigrationIsNoopWhenAlreadyApplied(t *testing.T) {
|
||||
// Given we have a migration with a post version that matches
|
||||
// the version in the config
|
||||
c := ghConfig.ReadFromString(testFullConfig())
|
||||
c.Set([]string{versionKey}, "expected-post-version")
|
||||
|
||||
migration := &ghmock.MigrationMock{
|
||||
DoFunc: func(config *ghConfig.Config) error {
|
||||
return errors.New("is not called")
|
||||
},
|
||||
PreVersionFunc: func() string {
|
||||
return "is not called"
|
||||
},
|
||||
PostVersionFunc: func() string {
|
||||
return "expected-post-version"
|
||||
},
|
||||
}
|
||||
|
||||
// When we run Migrate
|
||||
conf := cfg{c}
|
||||
err := conf.Migrate(migration)
|
||||
|
||||
// Then there is nothing done and the config is not modified
|
||||
require.NoError(t, err)
|
||||
requireKeyWithValue(t, c, []string{versionKey}, "expected-post-version")
|
||||
}
|
||||
|
||||
func TestMigrationErrorsWhenPreVersionMismatch(t *testing.T) {
|
||||
StubWriteConfig(t)
|
||||
|
||||
// Given we have a migration with a pre version that does not match
|
||||
// the version in the config
|
||||
c := ghConfig.ReadFromString(testFullConfig())
|
||||
c.Set([]string{versionKey}, "not-expected-pre-version")
|
||||
topLevelKey := []string{"toplevelkey"}
|
||||
|
||||
migration := &ghmock.MigrationMock{
|
||||
DoFunc: func(config *ghConfig.Config) error {
|
||||
config.Set(topLevelKey, "toplevelvalue")
|
||||
return nil
|
||||
},
|
||||
PreVersionFunc: func() string {
|
||||
return "expected-pre-version"
|
||||
},
|
||||
PostVersionFunc: func() string {
|
||||
return "not-expected"
|
||||
},
|
||||
}
|
||||
|
||||
// When we run Migrate
|
||||
conf := cfg{c}
|
||||
err := conf.Migrate(migration)
|
||||
|
||||
// Then there is an error the migration is not applied and the version is not modified
|
||||
require.ErrorContains(t, err, `failed to migrate as "expected-pre-version" pre migration version did not match config version "not-expected-pre-version"`)
|
||||
requireNoKey(t, c, topLevelKey)
|
||||
requireKeyWithValue(t, c, []string{versionKey}, "not-expected-pre-version")
|
||||
}
|
||||
|
||||
func TestMigrationErrorWritesNoFiles(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("GH_CONFIG_DIR", tempDir)
|
||||
|
||||
// Given we have a migrator that errors
|
||||
c := ghConfig.ReadFromString(testFullConfig())
|
||||
migration := mockMigration(func(config *ghConfig.Config) error {
|
||||
return errors.New("failed to migrate in test")
|
||||
})
|
||||
|
||||
// When we run the migration
|
||||
conf := cfg{c}
|
||||
err := conf.Migrate(migration)
|
||||
|
||||
// Then the error is wrapped and bubbled
|
||||
require.EqualError(t, err, "failed to migrate config: failed to migrate in test")
|
||||
|
||||
// And no files are written to disk
|
||||
files, err := os.ReadDir(tempDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, files, 0)
|
||||
}
|
||||
|
||||
func TestMigrationWriteErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
unwriteableFile string
|
||||
wantErrContains string
|
||||
}{
|
||||
{
|
||||
name: "failure to write hosts",
|
||||
unwriteableFile: "hosts.yml",
|
||||
wantErrContains: "failed to write config after migration",
|
||||
},
|
||||
{
|
||||
name: "failure to write config",
|
||||
unwriteableFile: "config.yml",
|
||||
wantErrContains: "failed to write config after migration",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("GH_CONFIG_DIR", tempDir)
|
||||
|
||||
// Given we error when writing the files (because we chmod the files as trickery)
|
||||
makeFileUnwriteable(t, filepath.Join(tempDir, tt.unwriteableFile))
|
||||
|
||||
c := ghConfig.ReadFromString(testFullConfig())
|
||||
topLevelKey := []string{"toplevelkey"}
|
||||
hostsKey := []string{hostsKey, "newhost"}
|
||||
|
||||
migration := mockMigration(func(someCfg *ghConfig.Config) error {
|
||||
someCfg.Set(topLevelKey, "toplevelvalue")
|
||||
someCfg.Set(hostsKey, "newhostvalue")
|
||||
return nil
|
||||
})
|
||||
|
||||
// When we run the migration
|
||||
conf := cfg{c}
|
||||
err := conf.Migrate(migration)
|
||||
|
||||
// Then the error is wrapped and bubbled
|
||||
require.ErrorContains(t, err, tt.wantErrContains)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func makeFileUnwriteable(t *testing.T, file string) {
|
||||
t.Helper()
|
||||
|
||||
f, err := os.Create(file)
|
||||
require.NoError(t, err)
|
||||
f.Close()
|
||||
|
||||
require.NoError(t, os.Chmod(file, 0000))
|
||||
}
|
||||
|
||||
func mockMigration(doFunc func(config *ghConfig.Config) error) *ghmock.MigrationMock {
|
||||
return &ghmock.MigrationMock{
|
||||
DoFunc: doFunc,
|
||||
PreVersionFunc: func() string {
|
||||
return ""
|
||||
},
|
||||
PostVersionFunc: func() string {
|
||||
return "not-expected"
|
||||
},
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func testFullConfig() string {
|
||||
var data = `
|
||||
git_protocol: ssh
|
||||
editor:
|
||||
prompt: enabled
|
||||
pager: less
|
||||
hosts:
|
||||
github.com:
|
||||
user: user1
|
||||
oauth_token: xxxxxxxxxxxxxxxxxxxx
|
||||
git_protocol: ssh
|
||||
enterprise.com:
|
||||
user: user2
|
||||
oauth_token: yyyyyyyyyyyyyyyyyyyy
|
||||
git_protocol: https
|
||||
`
|
||||
return data
|
||||
}
|
||||
224
internal/config/migration/multi_account.go
Normal file
224
internal/config/migration/multi_account.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
package migration
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/cli/cli/v2/internal/keyring"
|
||||
ghAPI "github.com/cli/go-gh/v2/pkg/api"
|
||||
"github.com/cli/go-gh/v2/pkg/config"
|
||||
)
|
||||
|
||||
var noTokenError = errors.New("no token found")
|
||||
|
||||
type CowardlyRefusalError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e CowardlyRefusalError) Error() string {
|
||||
// Consider whether we should add a call to action here like "open an issue with the contents of your redacted hosts.yml"
|
||||
return fmt.Sprintf("cowardly refusing to continue with multi account migration: %s", e.err.Error())
|
||||
}
|
||||
|
||||
var hostsKey = []string{"hosts"}
|
||||
|
||||
type tokenSource struct {
|
||||
token string
|
||||
inKeyring bool
|
||||
}
|
||||
|
||||
// This migration exists to take a hosts section of the following structure:
|
||||
//
|
||||
// github.com:
|
||||
// user: williammartin
|
||||
// git_protocol: https
|
||||
// editor: vim
|
||||
// github.localhost:
|
||||
// user: monalisa
|
||||
// git_protocol: https
|
||||
// oauth_token: xyz
|
||||
//
|
||||
// We want this to migrate to something like:
|
||||
//
|
||||
// github.com:
|
||||
// user: williammartin
|
||||
// git_protocol: https
|
||||
// editor: vim
|
||||
// users:
|
||||
// williammartin:
|
||||
//
|
||||
// github.localhost:
|
||||
// user: monalisa
|
||||
// git_protocol: https
|
||||
// oauth_token: xyz
|
||||
// users:
|
||||
// monalisa:
|
||||
// oauth_token: xyz
|
||||
//
|
||||
// For each hosts, we will create a new key `users` with the value of the host level
|
||||
// `user` key as a list entry. If there is a host level `oauth_token` we will
|
||||
// put that under the new user entry, otherwise there will be no value for the
|
||||
// new user key. No other host level configuration will be copied to the new user.
|
||||
//
|
||||
// The reason for this is that we can then add new users under a host.
|
||||
// Note that we are only copying the config under a new users key, and
|
||||
// under a specific user. The original config is left alone. This is to
|
||||
// allow forward compatibility for older versions of gh and also to avoid
|
||||
// breaking existing users of go-gh which looks at a specific location
|
||||
// in the config for oauth tokens that are stored insecurely.
|
||||
|
||||
type MultiAccount struct {
|
||||
// Allow injecting a transport layer in tests.
|
||||
Transport http.RoundTripper
|
||||
}
|
||||
|
||||
func (m MultiAccount) PreVersion() string {
|
||||
// It is expected that there is no version key since this migration
|
||||
// introduces it.
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m MultiAccount) PostVersion() string {
|
||||
return "1"
|
||||
}
|
||||
|
||||
func (m MultiAccount) Do(c *config.Config) error {
|
||||
hostnames, err := c.Keys(hostsKey)
|
||||
// [github.com, github.localhost]
|
||||
// We wouldn't expect to have a hosts key when this is the first time anyone
|
||||
// is logging in with the CLI.
|
||||
var keyNotFoundError *config.KeyNotFoundError
|
||||
if errors.As(err, &keyNotFoundError) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return CowardlyRefusalError{errors.New("couldn't get hosts configuration")}
|
||||
}
|
||||
|
||||
// If there are no hosts then it doesn't matter whether we migrate or not,
|
||||
// so lets avoid any confusion and say there's no migration required.
|
||||
if len(hostnames) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Otherwise let's get to the business of migrating!
|
||||
for _, hostname := range hostnames {
|
||||
tokenSource, err := getToken(c, hostname)
|
||||
// If no token existed for this host we'll remove the entry from the hosts file
|
||||
// by deleting it and moving on to the next one.
|
||||
if errors.Is(err, noTokenError) {
|
||||
// The only error that can be returned here is the key not existing, which
|
||||
// we know can't be true.
|
||||
_ = c.Remove(append(hostsKey, hostname))
|
||||
continue
|
||||
}
|
||||
// For any other error we'll error out
|
||||
if err != nil {
|
||||
return CowardlyRefusalError{fmt.Errorf("couldn't find oauth token for %q: %w", hostname, err)}
|
||||
}
|
||||
|
||||
username, err := getUsername(c, hostname, tokenSource.token, m.Transport)
|
||||
if err != nil {
|
||||
issueURL := "https://github.com/cli/cli/issues/8441"
|
||||
return CowardlyRefusalError{fmt.Errorf("couldn't get user name for %q please visit %s for help: %w", hostname, issueURL, err)}
|
||||
}
|
||||
|
||||
if err := migrateConfig(c, hostname, username); err != nil {
|
||||
return CowardlyRefusalError{fmt.Errorf("couldn't migrate config for %q: %w", hostname, err)}
|
||||
}
|
||||
|
||||
if err := migrateToken(hostname, username, tokenSource); err != nil {
|
||||
return CowardlyRefusalError{fmt.Errorf("couldn't migrate oauth token for %q: %w", hostname, err)}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getToken(c *config.Config, hostname string) (tokenSource, error) {
|
||||
if token, _ := c.Get(append(hostsKey, hostname, "oauth_token")); token != "" {
|
||||
return tokenSource{token: token, inKeyring: false}, nil
|
||||
}
|
||||
token, err := keyring.Get(keyringServiceName(hostname), "")
|
||||
|
||||
// If we have an error and it's not relating to there being no token
|
||||
// then we'll return the error cause that's really unexpected.
|
||||
if err != nil && !errors.Is(err, keyring.ErrNotFound) {
|
||||
return tokenSource{}, err
|
||||
}
|
||||
|
||||
// Otherwise we'll return a sentinel error
|
||||
if err != nil || token == "" {
|
||||
return tokenSource{}, noTokenError
|
||||
}
|
||||
|
||||
return tokenSource{
|
||||
token: token,
|
||||
inKeyring: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func getUsername(c *config.Config, hostname, token string, transport http.RoundTripper) (string, error) {
|
||||
username, _ := c.Get(append(hostsKey, hostname, "user"))
|
||||
if username != "" && username != "x-access-token" {
|
||||
return username, nil
|
||||
}
|
||||
opts := ghAPI.ClientOptions{
|
||||
Host: hostname,
|
||||
AuthToken: token,
|
||||
Transport: transport,
|
||||
}
|
||||
client, err := ghAPI.NewGraphQLClient(opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var query struct {
|
||||
Viewer struct {
|
||||
Login string
|
||||
}
|
||||
}
|
||||
err = client.Query("CurrentUser", &query, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return query.Viewer.Login, nil
|
||||
}
|
||||
|
||||
func migrateToken(hostname, username string, tokenSource tokenSource) error {
|
||||
// If token is not currently stored in the keyring do not migrate it,
|
||||
// as it is being stored in the config and is being handled when migrating the config.
|
||||
if !tokenSource.inKeyring {
|
||||
return nil
|
||||
}
|
||||
return keyring.Set(keyringServiceName(hostname), username, tokenSource.token)
|
||||
}
|
||||
|
||||
func migrateConfig(c *config.Config, hostname, username string) error {
|
||||
// Set the user key in case it was previously an anonymous user.
|
||||
c.Set(append(hostsKey, hostname, "user"), username)
|
||||
// Create the username key with an empty value so it will be
|
||||
// written even if there are no keys set under it.
|
||||
c.Set(append(hostsKey, hostname, "users", username), "")
|
||||
|
||||
insecureToken, err := c.Get(append(hostsKey, hostname, "oauth_token"))
|
||||
var keyNotFoundError *config.KeyNotFoundError
|
||||
// If there is no token then we're done here
|
||||
if errors.As(err, &keyNotFoundError) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If there's another error (current implementation doesn't have any other error but we'll be defensive)
|
||||
// then bubble something up.
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't get oauth token for %s: %s", hostname, err)
|
||||
}
|
||||
|
||||
// Otherwise we'll set the token under the new key
|
||||
c.Set(append(hostsKey, hostname, "users", username, "oauth_token"), insecureToken)
|
||||
return nil
|
||||
}
|
||||
|
||||
func keyringServiceName(hostname string) string {
|
||||
return "gh:" + hostname
|
||||
}
|
||||
249
internal/config/migration/multi_account_test.go
Normal file
249
internal/config/migration/multi_account_test.go
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
package migration_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/config/migration"
|
||||
"github.com/cli/cli/v2/internal/keyring"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/cli/go-gh/v2/pkg/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMigration(t *testing.T) {
|
||||
cfg := config.ReadFromString(`
|
||||
hosts:
|
||||
github.com:
|
||||
user: user1
|
||||
oauth_token: xxxxxxxxxxxxxxxxxxxx
|
||||
git_protocol: ssh
|
||||
enterprise.com:
|
||||
user: user2
|
||||
oauth_token: yyyyyyyyyyyyyyyyyyyy
|
||||
git_protocol: https
|
||||
`)
|
||||
|
||||
var m migration.MultiAccount
|
||||
require.NoError(t, m.Do(cfg))
|
||||
|
||||
// First we'll check that the oauth tokens have been moved to their new locations
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "user1", "oauth_token"}, "xxxxxxxxxxxxxxxxxxxx")
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "users", "user2", "oauth_token"}, "yyyyyyyyyyyyyyyyyyyy")
|
||||
|
||||
// Then we'll check that the old data has been left alone
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "user1")
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "oauth_token"}, "xxxxxxxxxxxxxxxxxxxx")
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "git_protocol"}, "ssh")
|
||||
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "user"}, "user2")
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "oauth_token"}, "yyyyyyyyyyyyyyyyyyyy")
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "git_protocol"}, "https")
|
||||
}
|
||||
|
||||
func TestMigrationSecureStorage(t *testing.T) {
|
||||
cfg := config.ReadFromString(`
|
||||
hosts:
|
||||
github.com:
|
||||
user: userOne
|
||||
git_protocol: ssh
|
||||
enterprise.com:
|
||||
user: userTwo
|
||||
git_protocol: https
|
||||
`)
|
||||
|
||||
userOneToken := "userOne-token"
|
||||
userTwoToken := "userTwo-token"
|
||||
|
||||
keyring.MockInit()
|
||||
require.NoError(t, keyring.Set("gh:github.com", "", userOneToken))
|
||||
require.NoError(t, keyring.Set("gh:enterprise.com", "", userTwoToken))
|
||||
|
||||
var m migration.MultiAccount
|
||||
require.NoError(t, m.Do(cfg))
|
||||
|
||||
// Verify token gets stored with host and username
|
||||
gotUserOneToken, err := keyring.Get("gh:github.com", "userOne")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userOneToken, gotUserOneToken)
|
||||
|
||||
// Verify token still exists with only host
|
||||
gotUserOneToken, err = keyring.Get("gh:github.com", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userOneToken, gotUserOneToken)
|
||||
|
||||
// Verify token gets stored with host and username
|
||||
gotUserTwoToken, err := keyring.Get("gh:enterprise.com", "userTwo")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userTwoToken, gotUserTwoToken)
|
||||
|
||||
// Verify token still exists with only host
|
||||
gotUserTwoToken, err = keyring.Get("gh:enterprise.com", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userTwoToken, gotUserTwoToken)
|
||||
|
||||
// First we'll check that the users have been created with no config underneath them
|
||||
requireKeyExists(t, cfg, []string{"hosts", "github.com", "users", "userOne"})
|
||||
requireKeyExists(t, cfg, []string{"hosts", "enterprise.com", "users", "userTwo"})
|
||||
|
||||
// Then we'll check that the old data has been left alone
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "userOne")
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "git_protocol"}, "ssh")
|
||||
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "user"}, "userTwo")
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "enterprise.com", "git_protocol"}, "https")
|
||||
}
|
||||
|
||||
func TestPreVersionIsEmptyString(t *testing.T) {
|
||||
var m migration.MultiAccount
|
||||
require.Equal(t, "", m.PreVersion())
|
||||
}
|
||||
|
||||
func TestPostVersion(t *testing.T) {
|
||||
var m migration.MultiAccount
|
||||
require.Equal(t, "1", m.PostVersion())
|
||||
}
|
||||
|
||||
func TestMigrationReturnsSuccessfullyWhenNoHostsEntry(t *testing.T) {
|
||||
cfg := config.ReadFromString(``)
|
||||
|
||||
var m migration.MultiAccount
|
||||
require.NoError(t, m.Do(cfg))
|
||||
}
|
||||
|
||||
func TestMigrationReturnsSuccessfullyWhenEmptyHosts(t *testing.T) {
|
||||
cfg := config.ReadFromString(`
|
||||
hosts:
|
||||
`)
|
||||
|
||||
var m migration.MultiAccount
|
||||
require.NoError(t, m.Do(cfg))
|
||||
}
|
||||
|
||||
func TestMigrationReturnsSuccessfullyWhenAnonymousUserExists(t *testing.T) {
|
||||
// Simulates config that gets generated when a user logs
|
||||
// in with a token and git protocol is not specified and
|
||||
// secure storage is used.
|
||||
token := "test-token"
|
||||
keyring.MockInit()
|
||||
require.NoError(t, keyring.Set("gh:github.com", "", token))
|
||||
|
||||
cfg := config.ReadFromString(`
|
||||
hosts:
|
||||
github.com:
|
||||
user: x-access-token
|
||||
`)
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query CurrentUser\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`),
|
||||
)
|
||||
|
||||
m := migration.MultiAccount{Transport: reg}
|
||||
require.NoError(t, m.Do(cfg))
|
||||
|
||||
require.Equal(t, fmt.Sprintf("token %s", token), reg.Requests[0].Header.Get("Authorization"))
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "monalisa")
|
||||
// monalisa key gets created with no value
|
||||
users, err := cfg.Keys([]string{"hosts", "github.com", "users"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"monalisa"}, users)
|
||||
|
||||
// Verify token gets stored with host and username
|
||||
gotToken, err := keyring.Get("gh:github.com", "monalisa")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token, gotToken)
|
||||
|
||||
// Verify token still exists with only host
|
||||
gotToken, err = keyring.Get("gh:github.com", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, token, gotToken)
|
||||
}
|
||||
|
||||
func TestMigrationReturnsSuccessfullyWhenAnonymousUserExistsAndInsecureStorage(t *testing.T) {
|
||||
// Simulates config that gets generated when a user logs
|
||||
// in with a token and git protocol is specified and
|
||||
// secure storage is not used.
|
||||
cfg := config.ReadFromString(`
|
||||
hosts:
|
||||
github.com:
|
||||
user: x-access-token
|
||||
oauth_token: test-token
|
||||
git_protocol: ssh
|
||||
`)
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query CurrentUser\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`),
|
||||
)
|
||||
|
||||
m := migration.MultiAccount{Transport: reg}
|
||||
require.NoError(t, m.Do(cfg))
|
||||
|
||||
require.Equal(t, "token test-token", reg.Requests[0].Header.Get("Authorization"))
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "user"}, "monalisa")
|
||||
requireKeyWithValue(t, cfg, []string{"hosts", "github.com", "users", "monalisa", "oauth_token"}, "test-token")
|
||||
}
|
||||
|
||||
func TestMigrationRemovesHostsWithInvalidTokens(t *testing.T) {
|
||||
// Simulates config when user is logged in securely
|
||||
// but no token entry is in the keyring.
|
||||
keyring.MockInit()
|
||||
cfg := config.ReadFromString(`
|
||||
hosts:
|
||||
github.com:
|
||||
user: user1
|
||||
git_protocol: ssh
|
||||
`)
|
||||
|
||||
m := migration.MultiAccount{}
|
||||
require.NoError(t, m.Do(cfg))
|
||||
|
||||
requireNoKey(t, cfg, []string{"hosts", "github.com"})
|
||||
}
|
||||
|
||||
func TestMigrationErrorsWhenUnableToGetExpectedSecureToken(t *testing.T) {
|
||||
// Simulates config when user is logged in securely
|
||||
// but no token entry is in the keyring.
|
||||
keyring.MockInitWithError(errors.New("keyring test error"))
|
||||
cfg := config.ReadFromString(`
|
||||
hosts:
|
||||
github.com:
|
||||
user: user1
|
||||
git_protocol: ssh
|
||||
`)
|
||||
|
||||
m := migration.MultiAccount{}
|
||||
err := m.Do(cfg)
|
||||
|
||||
require.ErrorContains(t, err, `couldn't find oauth token for "github.com": keyring test error`)
|
||||
}
|
||||
|
||||
func requireKeyExists(t *testing.T, cfg *config.Config, keys []string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := cfg.Get(keys)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func requireKeyWithValue(t *testing.T, cfg *config.Config, keys []string, value string) {
|
||||
t.Helper()
|
||||
|
||||
actual, err := cfg.Get(keys)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, value, actual)
|
||||
}
|
||||
|
||||
func requireNoKey(t *testing.T, cfg *config.Config, keys []string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := cfg.Get(keys)
|
||||
var keyNotFoundError *config.KeyNotFoundError
|
||||
require.ErrorAs(t, err, &keyNotFoundError)
|
||||
}
|
||||
|
|
@ -6,38 +6,22 @@ import (
|
|||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
ghmock "github.com/cli/cli/v2/internal/gh/mock"
|
||||
"github.com/cli/cli/v2/internal/keyring"
|
||||
o "github.com/cli/cli/v2/pkg/option"
|
||||
ghConfig "github.com/cli/go-gh/v2/pkg/config"
|
||||
)
|
||||
|
||||
func NewBlankConfig() *ConfigMock {
|
||||
defaultStr := `
|
||||
# What protocol to use when performing git operations. Supported values: ssh, https
|
||||
git_protocol: https
|
||||
# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.
|
||||
editor:
|
||||
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
||||
prompt: enabled
|
||||
# A pager program to send command output to, e.g. "less". Set the value to "cat" to disable the pager.
|
||||
pager:
|
||||
# Aliases allow you to create nicknames for gh commands
|
||||
aliases:
|
||||
co: pr checkout
|
||||
# The path to a unix socket through which send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.
|
||||
http_unix_socket:
|
||||
# What web browser gh should use when opening URLs. If blank, will refer to environment.
|
||||
browser:
|
||||
`
|
||||
return NewFromString(defaultStr)
|
||||
func NewBlankConfig() *ghmock.ConfigMock {
|
||||
return NewFromString(defaultConfigStr)
|
||||
}
|
||||
|
||||
func NewFromString(cfgStr string) *ConfigMock {
|
||||
func NewFromString(cfgStr string) *ghmock.ConfigMock {
|
||||
c := ghConfig.ReadFromString(cfgStr)
|
||||
cfg := cfg{c}
|
||||
mock := &ConfigMock{}
|
||||
mock.GetFunc = func(host, key string) (string, error) {
|
||||
return cfg.Get(host, key)
|
||||
}
|
||||
mock.GetOrDefaultFunc = func(host, key string) (string, error) {
|
||||
mock := &ghmock.ConfigMock{}
|
||||
mock.GetOrDefaultFunc = func(host, key string) o.Option[gh.ConfigEntry] {
|
||||
return cfg.GetOrDefault(host, key)
|
||||
}
|
||||
mock.SetFunc = func(host, key, value string) {
|
||||
|
|
@ -46,28 +30,86 @@ func NewFromString(cfgStr string) *ConfigMock {
|
|||
mock.WriteFunc = func() error {
|
||||
return cfg.Write()
|
||||
}
|
||||
mock.AliasesFunc = func() *AliasConfig {
|
||||
mock.MigrateFunc = func(m gh.Migration) error {
|
||||
return cfg.Migrate(m)
|
||||
}
|
||||
mock.AliasesFunc = func() gh.AliasConfig {
|
||||
return &AliasConfig{cfg: c}
|
||||
}
|
||||
mock.AuthenticationFunc = func() *AuthConfig {
|
||||
mock.AuthenticationFunc = func() gh.AuthConfig {
|
||||
return &AuthConfig{
|
||||
cfg: c,
|
||||
defaultHostOverride: func() (string, string) {
|
||||
return "github.com", "default"
|
||||
},
|
||||
hostsOverride: func() []string {
|
||||
keys, _ := c.Keys([]string{"hosts"})
|
||||
keys, _ := c.Keys([]string{hostsKey})
|
||||
return keys
|
||||
},
|
||||
tokenOverride: func(hostname string) (string, string) {
|
||||
token, _ := c.Get([]string{hosts, hostname, oauthToken})
|
||||
return token, "oauth_token"
|
||||
token, _ := c.Get([]string{hostsKey, hostname, oauthTokenKey})
|
||||
return token, oauthTokenKey
|
||||
},
|
||||
}
|
||||
}
|
||||
mock.BrowserFunc = func(hostname string) gh.ConfigEntry {
|
||||
return cfg.Browser(hostname)
|
||||
}
|
||||
mock.EditorFunc = func(hostname string) gh.ConfigEntry {
|
||||
return cfg.Editor(hostname)
|
||||
}
|
||||
mock.GitProtocolFunc = func(hostname string) gh.ConfigEntry {
|
||||
return cfg.GitProtocol(hostname)
|
||||
}
|
||||
mock.HTTPUnixSocketFunc = func(hostname string) gh.ConfigEntry {
|
||||
return cfg.HTTPUnixSocket(hostname)
|
||||
}
|
||||
mock.PagerFunc = func(hostname string) gh.ConfigEntry {
|
||||
return cfg.Pager(hostname)
|
||||
}
|
||||
mock.PromptFunc = func(hostname string) gh.ConfigEntry {
|
||||
return cfg.Prompt(hostname)
|
||||
}
|
||||
mock.VersionFunc = func() o.Option[string] {
|
||||
return cfg.Version()
|
||||
}
|
||||
mock.CacheDirFunc = func() string {
|
||||
return cfg.CacheDir()
|
||||
}
|
||||
return mock
|
||||
}
|
||||
|
||||
// NewIsolatedTestConfig sets up a Mock keyring, creates a blank config
|
||||
// overwrites the ghConfig.Read function that returns a singleton config
|
||||
// in the real implementation, sets the GH_CONFIG_DIR env var so that
|
||||
// any call to Write goes to a different location on disk, and then returns
|
||||
// the blank config and a function that reads any data written to disk.
|
||||
func NewIsolatedTestConfig(t *testing.T) (*cfg, func(io.Writer, io.Writer)) {
|
||||
keyring.MockInit()
|
||||
|
||||
c := ghConfig.ReadFromString("")
|
||||
cfg := cfg{c}
|
||||
|
||||
// The real implementation of config.Read uses a sync.Once
|
||||
// to read config files and initialise package level variables
|
||||
// that are used from then on.
|
||||
//
|
||||
// This means that tests can't be isolated from each other, so
|
||||
// we swap out the function here to return a new config each time.
|
||||
ghConfig.Read = func(_ *ghConfig.Config) (*ghConfig.Config, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// The config.Write method isn't defined in the same way as Read to allow
|
||||
// the function to be swapped out and it does try to write to disk.
|
||||
//
|
||||
// We should consider whether it makes sense to change that but in the meantime
|
||||
// we can use GH_CONFIG_DIR env var to ensure the tests remain isolated.
|
||||
readConfigs := StubWriteConfig(t)
|
||||
|
||||
return &cfg, readConfigs
|
||||
}
|
||||
|
||||
// StubWriteConfig stubs out the filesystem where config file are written.
|
||||
// It then returns a function that will read in the config files into io.Writers.
|
||||
// It automatically cleans up environment variables and written files.
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ func init() {
|
|||
printCmd.Flags().IntP("intthree", "i", 345, "help message for flag intthree")
|
||||
printCmd.Flags().BoolP("boolthree", "b", true, "help message for flag boolthree")
|
||||
|
||||
jsonCmd.Flags().StringSlice("json", nil, "help message for flag json")
|
||||
|
||||
aliasCmd.Flags().StringSlice("yang", nil, "help message for flag yang")
|
||||
|
||||
echoCmd.AddCommand(timesCmd, echoSubCmd, deprecatedCmd)
|
||||
rootCmd.AddCommand(printCmd, echoCmd, dummyCmd)
|
||||
}
|
||||
|
|
@ -73,6 +77,21 @@ var printCmd = &cobra.Command{
|
|||
Long: `an absolutely utterly useless command for testing.`,
|
||||
}
|
||||
|
||||
var aliasCmd = &cobra.Command{
|
||||
Use: "ying [yang]",
|
||||
Short: "The ying and yang of it all",
|
||||
Long: "an absolutely utterly useless command for testing aliases!.",
|
||||
Aliases: []string{"yoo", "foo"},
|
||||
}
|
||||
|
||||
var jsonCmd = &cobra.Command{
|
||||
Use: "blah --json <fields>",
|
||||
Short: "View details in JSON",
|
||||
Annotations: map[string]string{
|
||||
"help:json-fields": "foo,bar,baz",
|
||||
},
|
||||
}
|
||||
|
||||
var dummyCmd = &cobra.Command{
|
||||
Use: "dummy [action]",
|
||||
Short: "Performs a dummy action",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/cmd/root"
|
||||
"github.com/cpuguy83/go-md2man/v2/md2man"
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -149,10 +150,15 @@ func manPrintFlags(buf *bytes.Buffer, flags *pflag.FlagSet) {
|
|||
} else {
|
||||
buf.WriteString(fmt.Sprintf("`--%s`", flag.Name))
|
||||
}
|
||||
if varname == "" {
|
||||
|
||||
defval := getDefaultValueDisplayString(flag)
|
||||
|
||||
if varname == "" && defval != "" {
|
||||
buf.WriteString(fmt.Sprintf(" `%s`\n", strings.TrimSpace(defval)))
|
||||
} else if varname == "" {
|
||||
buf.WriteString("\n")
|
||||
} else {
|
||||
buf.WriteString(fmt.Sprintf(" `<%s>`\n", varname))
|
||||
buf.WriteString(fmt.Sprintf(" `<%s>%s`\n", varname, defval))
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf(": %s\n\n", usage))
|
||||
})
|
||||
|
|
@ -173,6 +179,25 @@ func manPrintOptions(buf *bytes.Buffer, command *cobra.Command) {
|
|||
}
|
||||
}
|
||||
|
||||
func manPrintAliases(buf *bytes.Buffer, command *cobra.Command) {
|
||||
if len(command.Aliases) > 0 {
|
||||
buf.WriteString("# ALIASES\n")
|
||||
buf.WriteString(strings.Join(root.BuildAliasList(command, command.Aliases), ", "))
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func manPrintJSONFields(buf *bytes.Buffer, command *cobra.Command) {
|
||||
raw, ok := command.Annotations["help:json-fields"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
buf.WriteString("# JSON FIELDS\n")
|
||||
buf.WriteString(text.FormatSlice(strings.Split(raw, ","), 0, 0, "`", "`", true))
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
|
||||
func genMan(cmd *cobra.Command, header *GenManHeader) []byte {
|
||||
cmd.InitDefaultHelpCmd()
|
||||
cmd.InitDefaultHelpFlag()
|
||||
|
|
@ -190,6 +215,8 @@ func genMan(cmd *cobra.Command, header *GenManHeader) []byte {
|
|||
}
|
||||
}
|
||||
manPrintOptions(buf, cmd)
|
||||
manPrintAliases(buf, cmd)
|
||||
manPrintJSONFields(buf, cmd)
|
||||
if len(cmd.Example) > 0 {
|
||||
buf.WriteString("# EXAMPLE\n")
|
||||
buf.WriteString(fmt.Sprintf("```\n%s\n```\n", cmd.Example))
|
||||
|
|
|
|||
|
|
@ -98,6 +98,37 @@ func TestGenManSeeAlso(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestGenManAliases(t *testing.T) {
|
||||
buf := new(bytes.Buffer)
|
||||
header := &GenManHeader{}
|
||||
if err := renderMan(aliasCmd, header, buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
checkStringContains(t, output, translate(aliasCmd.Name()))
|
||||
checkStringContains(t, output, "ALIASES")
|
||||
checkStringContains(t, output, "foo")
|
||||
checkStringContains(t, output, "yoo")
|
||||
}
|
||||
|
||||
func TestGenManJSONFields(t *testing.T) {
|
||||
buf := new(bytes.Buffer)
|
||||
header := &GenManHeader{}
|
||||
if err := renderMan(jsonCmd, header, buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
output := buf.String()
|
||||
|
||||
checkStringContains(t, output, translate(jsonCmd.Name()))
|
||||
checkStringContains(t, output, "JSON FIELDS")
|
||||
checkStringContains(t, output, "foo")
|
||||
checkStringContains(t, output, "bar")
|
||||
checkStringContains(t, output, "baz")
|
||||
}
|
||||
|
||||
func TestManPrintFlagsHidesShortDeprecated(t *testing.T) {
|
||||
c := &cobra.Command{}
|
||||
c.Flags().StringP("foo", "f", "default", "Foo flag")
|
||||
|
|
@ -107,7 +138,7 @@ func TestManPrintFlagsHidesShortDeprecated(t *testing.T) {
|
|||
manPrintFlags(buf, c.Flags())
|
||||
|
||||
got := buf.String()
|
||||
expected := "`--foo` `<string>`\n: Foo flag\n\n"
|
||||
expected := "`--foo` `<string> (default \"default\")`\n: Foo flag\n\n"
|
||||
if got != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, got)
|
||||
}
|
||||
|
|
@ -130,6 +161,107 @@ func TestGenManTree(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestManPrintFlagsShowsDefaultValues(t *testing.T) {
|
||||
type TestOptions struct {
|
||||
Limit int
|
||||
Template string
|
||||
Fork bool
|
||||
NoArchive bool
|
||||
Topic []string
|
||||
}
|
||||
opts := TestOptions{}
|
||||
// Int flag should show it
|
||||
c := &cobra.Command{}
|
||||
c.Flags().IntVar(&opts.Limit, "limit", 30, "Some limit")
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
manPrintFlags(buf, c.Flags())
|
||||
|
||||
got := buf.String()
|
||||
expected := "`--limit` `<int> (default 30)`\n: Some limit\n\n"
|
||||
if got != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, got)
|
||||
}
|
||||
|
||||
// Bool flag should hide it if default is false
|
||||
c = &cobra.Command{}
|
||||
c.Flags().BoolVar(&opts.Fork, "fork", false, "Show only forks")
|
||||
|
||||
buf = new(bytes.Buffer)
|
||||
manPrintFlags(buf, c.Flags())
|
||||
|
||||
got = buf.String()
|
||||
expected = "`--fork`\n: Show only forks\n\n"
|
||||
if got != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, got)
|
||||
}
|
||||
|
||||
// Bool flag should show it if default is true
|
||||
c = &cobra.Command{}
|
||||
c.Flags().BoolVar(&opts.NoArchive, "no-archived", true, "Hide archived")
|
||||
|
||||
buf = new(bytes.Buffer)
|
||||
manPrintFlags(buf, c.Flags())
|
||||
|
||||
got = buf.String()
|
||||
expected = "`--no-archived` `(default true)`\n: Hide archived\n\n"
|
||||
if got != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, got)
|
||||
}
|
||||
|
||||
// String flag should show it if default is not an empty string
|
||||
c = &cobra.Command{}
|
||||
c.Flags().StringVar(&opts.Template, "template", "T1", "Some template")
|
||||
|
||||
buf = new(bytes.Buffer)
|
||||
manPrintFlags(buf, c.Flags())
|
||||
|
||||
got = buf.String()
|
||||
expected = "`--template` `<string> (default \"T1\")`\n: Some template\n\n"
|
||||
if got != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, got)
|
||||
}
|
||||
|
||||
// String flag should hide it if default is an empty string
|
||||
c = &cobra.Command{}
|
||||
c.Flags().StringVar(&opts.Template, "template", "", "Some template")
|
||||
|
||||
buf = new(bytes.Buffer)
|
||||
manPrintFlags(buf, c.Flags())
|
||||
|
||||
got = buf.String()
|
||||
expected = "`--template` `<string>`\n: Some template\n\n"
|
||||
if got != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, got)
|
||||
}
|
||||
|
||||
// String slice flag should hide it if default is an empty slice
|
||||
c = &cobra.Command{}
|
||||
c.Flags().StringSliceVar(&opts.Topic, "topic", nil, "Some topics")
|
||||
|
||||
buf = new(bytes.Buffer)
|
||||
manPrintFlags(buf, c.Flags())
|
||||
|
||||
got = buf.String()
|
||||
expected = "`--topic` `<strings>`\n: Some topics\n\n"
|
||||
if got != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, got)
|
||||
}
|
||||
|
||||
// String slice flag should show it if default is not an empty slice
|
||||
c = &cobra.Command{}
|
||||
c.Flags().StringSliceVar(&opts.Topic, "topic", []string{"apples", "oranges"}, "Some topics")
|
||||
|
||||
buf = new(bytes.Buffer)
|
||||
manPrintFlags(buf, c.Flags())
|
||||
|
||||
got = buf.String()
|
||||
expected = "`--topic` `<strings> (default [apples,oranges])`\n: Some topics\n\n"
|
||||
if got != expected {
|
||||
t.Errorf("Expected %q, got %q", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func assertLineFound(scanner *bufio.Scanner, expectedLine string) error {
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
|
|
|
|||
|
|
@ -8,12 +8,33 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/cmd/root"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/cobra/doc"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func printJSONFields(w io.Writer, cmd *cobra.Command) {
|
||||
raw, ok := cmd.Annotations["help:json-fields"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprint(w, "### JSON Fields\n\n")
|
||||
fmt.Fprint(w, text.FormatSlice(strings.Split(raw, ","), 0, 0, "`", "`", true))
|
||||
fmt.Fprint(w, "\n\n")
|
||||
}
|
||||
|
||||
func printAliases(w io.Writer, cmd *cobra.Command) {
|
||||
if len(cmd.Aliases) > 0 {
|
||||
fmt.Fprintf(w, "### ALIASES\n\n")
|
||||
fmt.Fprint(w, text.FormatSlice(strings.Split(strings.Join(root.BuildAliasList(cmd, cmd.Aliases), ", "), ","), 0, 0, "", "", true))
|
||||
fmt.Fprint(w, "\n\n")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func printOptions(w io.Writer, cmd *cobra.Command) error {
|
||||
flags := cmd.NonInheritedFlags()
|
||||
flags.SetOutput(w)
|
||||
|
|
@ -46,17 +67,43 @@ func hasNonHelpFlags(fs *pflag.FlagSet) (found bool) {
|
|||
return
|
||||
}
|
||||
|
||||
var hiddenFlagDefaults = map[string]bool{
|
||||
"false": true,
|
||||
"": true,
|
||||
"[]": true,
|
||||
"0s": true,
|
||||
}
|
||||
|
||||
var defaultValFormats = map[string]string{
|
||||
"string": " (default \"%s\")",
|
||||
"duration": " (default \"%s\")",
|
||||
}
|
||||
|
||||
func getDefaultValueDisplayString(f *pflag.Flag) string {
|
||||
|
||||
if hiddenFlagDefaults[f.DefValue] || hiddenFlagDefaults[f.Value.Type()] {
|
||||
return ""
|
||||
}
|
||||
|
||||
if dvf, found := defaultValFormats[f.Value.Type()]; found {
|
||||
return fmt.Sprintf(dvf, f.Value)
|
||||
}
|
||||
return fmt.Sprintf(" (default %s)", f.Value)
|
||||
|
||||
}
|
||||
|
||||
type flagView struct {
|
||||
Name string
|
||||
Varname string
|
||||
Shorthand string
|
||||
DefValue string
|
||||
Usage string
|
||||
}
|
||||
|
||||
var flagsTemplate = `
|
||||
<dl class="flags">{{ range . }}
|
||||
<dt>{{ if .Shorthand }}<code>-{{.Shorthand}}</code>, {{ end -}}
|
||||
<code>--{{.Name}}{{ if .Varname }} <{{.Varname}}>{{ end }}</code></dt>
|
||||
<dt>{{ if .Shorthand }}<code>-{{.Shorthand}}</code>, {{ end }}
|
||||
<code>--{{.Name}}{{ if .Varname }} <{{.Varname}}>{{ end }}{{.DefValue}} </code></dt>
|
||||
<dd>{{.Usage}}</dd>
|
||||
{{ end }}</dl>
|
||||
`
|
||||
|
|
@ -70,10 +117,12 @@ func printFlagsHTML(w io.Writer, fs *pflag.FlagSet) error {
|
|||
return
|
||||
}
|
||||
varname, usage := pflag.UnquoteUsage(f)
|
||||
|
||||
flags = append(flags, flagView{
|
||||
Name: f.Name,
|
||||
Varname: varname,
|
||||
Shorthand: f.Shorthand,
|
||||
DefValue: getDefaultValueDisplayString(f),
|
||||
Usage: usage,
|
||||
})
|
||||
})
|
||||
|
|
@ -107,6 +156,8 @@ func genMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string)
|
|||
if err := printOptions(w, cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
printAliases(w, cmd)
|
||||
printJSONFields(w, cmd)
|
||||
fmt.Fprint(w, "{% endraw %}\n")
|
||||
|
||||
if len(cmd.Example) > 0 {
|
||||
|
|
|
|||
|
|
@ -70,6 +70,35 @@ func TestGenMdNoHiddenParents(t *testing.T) {
|
|||
checkStringOmits(t, output, "Options inherited from parent commands")
|
||||
}
|
||||
|
||||
func TestGenMdAliases(t *testing.T) {
|
||||
buf := new(bytes.Buffer)
|
||||
if err := genMarkdownCustom(aliasCmd, buf, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := buf.String()
|
||||
|
||||
checkStringContains(t, output, aliasCmd.Long)
|
||||
checkStringContains(t, output, jsonCmd.Example)
|
||||
checkStringContains(t, output, "ALIASES")
|
||||
checkStringContains(t, output, "yoo")
|
||||
checkStringContains(t, output, "foo")
|
||||
}
|
||||
|
||||
func TestGenMdJSONFields(t *testing.T) {
|
||||
buf := new(bytes.Buffer)
|
||||
if err := genMarkdownCustom(jsonCmd, buf, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := buf.String()
|
||||
|
||||
checkStringContains(t, output, jsonCmd.Long)
|
||||
checkStringContains(t, output, jsonCmd.Example)
|
||||
checkStringContains(t, output, "JSON Fields")
|
||||
checkStringContains(t, output, "`foo`")
|
||||
checkStringContains(t, output, "`bar`")
|
||||
checkStringContains(t, output, "`baz`")
|
||||
}
|
||||
|
||||
func TestGenMdTree(t *testing.T) {
|
||||
c := &cobra.Command{Use: "do [OPTIONS] arg1 arg2"}
|
||||
tmpdir, err := os.MkdirTemp("", "test-gen-md-tree")
|
||||
|
|
@ -103,3 +132,115 @@ func BenchmarkGenMarkdownToFile(b *testing.B) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintFlagsHTMLShowsDefaultValues(t *testing.T) {
|
||||
|
||||
type TestOptions struct {
|
||||
Limit int
|
||||
Template string
|
||||
Fork bool
|
||||
NoArchive bool
|
||||
Topic []string
|
||||
}
|
||||
opts := TestOptions{}
|
||||
|
||||
// Int flag should show it
|
||||
c := &cobra.Command{}
|
||||
c.Flags().IntVar(&opts.Limit, "limit", 30, "Some limit")
|
||||
flags := c.NonInheritedFlags()
|
||||
buf := new(bytes.Buffer)
|
||||
flags.SetOutput(buf)
|
||||
|
||||
if err := printFlagsHTML(buf, flags); err != nil {
|
||||
t.Fatalf("printFlagsHTML failed: %s", err.Error())
|
||||
}
|
||||
output := buf.String()
|
||||
|
||||
checkStringContains(t, output, "(default 30)")
|
||||
|
||||
// Bool flag should hide it if default is false
|
||||
c = &cobra.Command{}
|
||||
c.Flags().BoolVar(&opts.Fork, "fork", false, "Show only forks")
|
||||
|
||||
flags = c.NonInheritedFlags()
|
||||
buf = new(bytes.Buffer)
|
||||
flags.SetOutput(buf)
|
||||
|
||||
if err := printFlagsHTML(buf, flags); err != nil {
|
||||
t.Fatalf("printFlagsHTML failed: %s", err.Error())
|
||||
}
|
||||
output = buf.String()
|
||||
|
||||
checkStringOmits(t, output, "(default ")
|
||||
|
||||
// Bool flag should show it if default is true
|
||||
c = &cobra.Command{}
|
||||
c.Flags().BoolVar(&opts.NoArchive, "no-archived", true, "Hide archived")
|
||||
flags = c.NonInheritedFlags()
|
||||
buf = new(bytes.Buffer)
|
||||
flags.SetOutput(buf)
|
||||
|
||||
if err := printFlagsHTML(buf, flags); err != nil {
|
||||
t.Fatalf("printFlagsHTML failed: %s", err.Error())
|
||||
}
|
||||
output = buf.String()
|
||||
|
||||
checkStringContains(t, output, "(default true)")
|
||||
|
||||
// String flag should show it if default is not an empty string
|
||||
c = &cobra.Command{}
|
||||
c.Flags().StringVar(&opts.Template, "template", "T1", "Some template")
|
||||
flags = c.NonInheritedFlags()
|
||||
buf = new(bytes.Buffer)
|
||||
flags.SetOutput(buf)
|
||||
|
||||
if err := printFlagsHTML(buf, flags); err != nil {
|
||||
t.Fatalf("printFlagsHTML failed: %s", err.Error())
|
||||
}
|
||||
output = buf.String()
|
||||
|
||||
checkStringContains(t, output, "(default "T1")")
|
||||
|
||||
// String flag should hide it if default is an empty string
|
||||
c = &cobra.Command{}
|
||||
c.Flags().StringVar(&opts.Template, "template", "", "Some template")
|
||||
|
||||
flags = c.NonInheritedFlags()
|
||||
buf = new(bytes.Buffer)
|
||||
flags.SetOutput(buf)
|
||||
|
||||
if err := printFlagsHTML(buf, flags); err != nil {
|
||||
t.Fatalf("printFlagsHTML failed: %s", err.Error())
|
||||
}
|
||||
output = buf.String()
|
||||
|
||||
checkStringOmits(t, output, "(default ")
|
||||
|
||||
// String slice flag should hide it if default is an empty slice
|
||||
c = &cobra.Command{}
|
||||
c.Flags().StringSliceVar(&opts.Topic, "topic", nil, "Some topics")
|
||||
flags = c.NonInheritedFlags()
|
||||
buf = new(bytes.Buffer)
|
||||
flags.SetOutput(buf)
|
||||
|
||||
if err := printFlagsHTML(buf, flags); err != nil {
|
||||
t.Fatalf("printFlagsHTML failed: %s", err.Error())
|
||||
}
|
||||
output = buf.String()
|
||||
|
||||
checkStringOmits(t, output, "(default ")
|
||||
|
||||
// String slice flag should show it if default is not an empty slice
|
||||
c = &cobra.Command{}
|
||||
c.Flags().StringSliceVar(&opts.Topic, "topic", []string{"apples", "oranges"}, "Some topics")
|
||||
flags = c.NonInheritedFlags()
|
||||
buf = new(bytes.Buffer)
|
||||
flags.SetOutput(buf)
|
||||
|
||||
if err := printFlagsHTML(buf, flags); err != nil {
|
||||
t.Fatalf("printFlagsHTML failed: %s", err.Error())
|
||||
}
|
||||
output = buf.String()
|
||||
|
||||
checkStringContains(t, output, "(default [apples,oranges])")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ var allIssueFeatures = IssueFeatures{
|
|||
type PullRequestFeatures struct {
|
||||
MergeQueue bool
|
||||
// CheckRunAndStatusContextCounts indicates whether the API supports
|
||||
// the checkRunCount, checkRunCountsByState, statusContextCount and stausContextCountsByState
|
||||
// the checkRunCount, checkRunCountsByState, statusContextCount and statusContextCountsByState
|
||||
// fields on the StatusCheckRollupContextConnection
|
||||
CheckRunAndStatusContextCounts bool
|
||||
CheckRunEvent bool
|
||||
|
|
|
|||
171
internal/gh/gh.go
Normal file
171
internal/gh/gh.go
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
// Package gh provides types that represent the domain of the CLI application.
|
||||
//
|
||||
// For example, the CLI expects to be able to get and set user configuration in order to perform its functionality,
|
||||
// so the Config interface is defined here, though the concrete implementation lives elsewhere. Though the current
|
||||
// implementation of config writes to certain files on disk, that is an implementation detail compared to the contract
|
||||
// laid out in the interface here.
|
||||
//
|
||||
// Currently this package is in an early state but we could imagine other domain concepts living here for interacting
|
||||
// with git or GitHub.
|
||||
package gh
|
||||
|
||||
import (
|
||||
o "github.com/cli/cli/v2/pkg/option"
|
||||
ghConfig "github.com/cli/go-gh/v2/pkg/config"
|
||||
)
|
||||
|
||||
type ConfigSource string
|
||||
|
||||
const (
|
||||
ConfigDefaultProvided ConfigSource = "default"
|
||||
ConfigUserProvided ConfigSource = "user"
|
||||
)
|
||||
|
||||
type ConfigEntry struct {
|
||||
Value string
|
||||
Source ConfigSource
|
||||
}
|
||||
|
||||
// A Config implements persistent storage and modification of application configuration.
|
||||
//
|
||||
//go:generate moq -rm -pkg ghmock -out mock/config.go . Config
|
||||
type Config interface {
|
||||
// GetOrDefault provides primitive access for fetching configuration values, optionally scoped by host.
|
||||
GetOrDefault(hostname string, key string) o.Option[ConfigEntry]
|
||||
// Set provides primitive access for setting configuration values, optionally scoped by host.
|
||||
Set(hostname string, key string, value string)
|
||||
|
||||
// Browser returns the configured browser, optionally scoped by host.
|
||||
Browser(hostname string) ConfigEntry
|
||||
// Editor returns the configured editor, optionally scoped by host.
|
||||
Editor(hostname string) ConfigEntry
|
||||
// GitProtocol returns the configured git protocol, optionally scoped by host.
|
||||
GitProtocol(hostname string) ConfigEntry
|
||||
// HTTPUnixSocket returns the configured HTTP unix socket, optionally scoped by host.
|
||||
HTTPUnixSocket(hostname string) ConfigEntry
|
||||
// Pager returns the configured Pager, optionally scoped by host.
|
||||
Pager(hostname string) ConfigEntry
|
||||
// Prompt returns the configured prompt, optionally scoped by host.
|
||||
Prompt(hostname string) ConfigEntry
|
||||
|
||||
// Aliases provides persistent storage and modification of command aliases.
|
||||
Aliases() AliasConfig
|
||||
|
||||
// Authentication provides persistent storage and modification of authentication configuration.
|
||||
Authentication() AuthConfig
|
||||
|
||||
// CacheDir returns the directory where the cacheable artifacts can be persisted.
|
||||
CacheDir() string
|
||||
|
||||
// Migrate applies a migration to the configuration.
|
||||
Migrate(Migration) error
|
||||
|
||||
// Version returns the current schema version of the configuration.
|
||||
Version() o.Option[string]
|
||||
|
||||
// Write persists modifications to the configuration.
|
||||
Write() error
|
||||
}
|
||||
|
||||
// Migration is the interface that config migrations must implement.
|
||||
//
|
||||
// Migrations will receive a copy of the config, and should modify that copy
|
||||
// as necessary. After migration has completed, the modified config contents
|
||||
// will be used.
|
||||
//
|
||||
// The calling code is expected to verify that the current version of the config
|
||||
// matches the PreVersion of the migration before calling Do, and will set the
|
||||
// config version to the PostVersion after the migration has completed successfully.
|
||||
//
|
||||
//go:generate moq -rm -pkg ghmock -out mock/migration.go . Migration
|
||||
type Migration interface {
|
||||
// PreVersion is the required config version for this to be applied
|
||||
PreVersion() string
|
||||
// PostVersion is the config version that must be applied after migration
|
||||
PostVersion() string
|
||||
// Do is expected to apply any necessary changes to the config in place
|
||||
Do(*ghConfig.Config) error
|
||||
}
|
||||
|
||||
// AuthConfig is used for interacting with some persistent configuration for gh,
|
||||
// with knowledge on how to access encrypted storage when neccesarry.
|
||||
// Behavior is scoped to authentication specific tasks.
|
||||
type AuthConfig interface {
|
||||
// ActiveToken will retrieve the active auth token for the given hostname, searching environment variables,
|
||||
// general configuration, and finally encrypted storage.
|
||||
ActiveToken(hostname string) (token string, source string)
|
||||
|
||||
// HasEnvToken returns true when a token has been specified in an environment variable, else returns false.
|
||||
HasEnvToken() bool
|
||||
|
||||
// TokenFromKeyring will retrieve the auth token for the given hostname, only searching in encrypted storage.
|
||||
TokenFromKeyring(hostname string) (token string, err error)
|
||||
|
||||
// TokenFromKeyringForUser will retrieve the auth token for the given hostname and username, only searching
|
||||
// in encrypted storage.
|
||||
//
|
||||
// An empty username will return an error because the potential to return the currently active token under
|
||||
// surprising cases is just too high to risk compared to the utility of having the function being smart.
|
||||
TokenFromKeyringForUser(hostname, username string) (token string, err error)
|
||||
|
||||
// ActiveUser will retrieve the username for the active user at the given hostname.
|
||||
//
|
||||
// This will not be accurate if the oauth token is set from an environment variable.
|
||||
ActiveUser(hostname string) (username string, err error)
|
||||
|
||||
// Hosts retrieves a list of known hosts.
|
||||
Hosts() []string
|
||||
|
||||
// DefaultHost retrieves the default host.
|
||||
DefaultHost() (host string, source string)
|
||||
|
||||
// Login will set user, git protocol, and auth token for the given hostname.
|
||||
//
|
||||
// If the encrypt option is specified it will first try to store the auth token
|
||||
// in encrypted storage and will fall back to the general insecure configuration.
|
||||
Login(hostname, username, token, gitProtocol string, secureStorage bool) (insecureStorageUsed bool, err error)
|
||||
|
||||
// SwitchUser switches the active user for a given hostname.
|
||||
SwitchUser(hostname, user string) error
|
||||
|
||||
// Logout will remove user, git protocol, and auth token for the given hostname.
|
||||
// It will remove the auth token from the encrypted storage if it exists there.
|
||||
Logout(hostname, username string) error
|
||||
|
||||
// UsersForHost retrieves a list of users configured for a specific host.
|
||||
UsersForHost(hostname string) []string
|
||||
|
||||
// TokenForUser retrieves the authentication token and its source for a specified user and hostname.
|
||||
TokenForUser(hostname, user string) (token string, source string, err error)
|
||||
|
||||
// The following methods are only for testing and that is a design smell we should consider fixing.
|
||||
|
||||
// SetActiveToken will override any token resolution and return the given token and source for all calls to
|
||||
// ActiveToken.
|
||||
// Use for testing purposes only.
|
||||
SetActiveToken(token, source string)
|
||||
|
||||
// SetHosts will override any hosts resolution and return the given hosts for all calls to Hosts.
|
||||
// Use for testing purposes only.
|
||||
SetHosts(hosts []string)
|
||||
|
||||
// SetDefaultHost will override any host resolution and return the given host and source for all calls to
|
||||
// DefaultHost.
|
||||
// Use for testing purposes only.
|
||||
SetDefaultHost(host, source string)
|
||||
}
|
||||
|
||||
// AliasConfig defines an interface for managing command aliases.
|
||||
type AliasConfig interface {
|
||||
// Get retrieves the expansion for a specified alias.
|
||||
Get(alias string) (expansion string, err error)
|
||||
|
||||
// Add adds a new alias with the specified expansion.
|
||||
Add(alias, expansion string)
|
||||
|
||||
// Delete removes an alias.
|
||||
Delete(alias string) error
|
||||
|
||||
// All returns a map of all aliases to their corresponding expansions.
|
||||
All() map[string]string
|
||||
}
|
||||
631
internal/gh/mock/config.go
Normal file
631
internal/gh/mock/config.go
Normal file
|
|
@ -0,0 +1,631 @@
|
|||
// Code generated by moq; DO NOT EDIT.
|
||||
// github.com/matryer/moq
|
||||
|
||||
package ghmock
|
||||
|
||||
import (
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
o "github.com/cli/cli/v2/pkg/option"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Ensure, that ConfigMock does implement gh.Config.
|
||||
// If this is not the case, regenerate this file with moq.
|
||||
var _ gh.Config = &ConfigMock{}
|
||||
|
||||
// ConfigMock is a mock implementation of gh.Config.
|
||||
//
|
||||
// func TestSomethingThatUsesConfig(t *testing.T) {
|
||||
//
|
||||
// // make and configure a mocked gh.Config
|
||||
// mockedConfig := &ConfigMock{
|
||||
// AliasesFunc: func() gh.AliasConfig {
|
||||
// panic("mock out the Aliases method")
|
||||
// },
|
||||
// AuthenticationFunc: func() gh.AuthConfig {
|
||||
// panic("mock out the Authentication method")
|
||||
// },
|
||||
// BrowserFunc: func(hostname string) gh.ConfigEntry {
|
||||
// panic("mock out the Browser method")
|
||||
// },
|
||||
// CacheDirFunc: func() string {
|
||||
// panic("mock out the CacheDir method")
|
||||
// },
|
||||
// EditorFunc: func(hostname string) gh.ConfigEntry {
|
||||
// panic("mock out the Editor method")
|
||||
// },
|
||||
// GetOrDefaultFunc: func(hostname string, key string) o.Option[gh.ConfigEntry] {
|
||||
// panic("mock out the GetOrDefault method")
|
||||
// },
|
||||
// GitProtocolFunc: func(hostname string) gh.ConfigEntry {
|
||||
// panic("mock out the GitProtocol method")
|
||||
// },
|
||||
// HTTPUnixSocketFunc: func(hostname string) gh.ConfigEntry {
|
||||
// panic("mock out the HTTPUnixSocket method")
|
||||
// },
|
||||
// MigrateFunc: func(migration gh.Migration) error {
|
||||
// panic("mock out the Migrate method")
|
||||
// },
|
||||
// PagerFunc: func(hostname string) gh.ConfigEntry {
|
||||
// panic("mock out the Pager method")
|
||||
// },
|
||||
// PromptFunc: func(hostname string) gh.ConfigEntry {
|
||||
// panic("mock out the Prompt method")
|
||||
// },
|
||||
// SetFunc: func(hostname string, key string, value string) {
|
||||
// panic("mock out the Set method")
|
||||
// },
|
||||
// VersionFunc: func() o.Option[string] {
|
||||
// panic("mock out the Version method")
|
||||
// },
|
||||
// WriteFunc: func() error {
|
||||
// panic("mock out the Write method")
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// // use mockedConfig in code that requires gh.Config
|
||||
// // and then make assertions.
|
||||
//
|
||||
// }
|
||||
type ConfigMock struct {
|
||||
// AliasesFunc mocks the Aliases method.
|
||||
AliasesFunc func() gh.AliasConfig
|
||||
|
||||
// AuthenticationFunc mocks the Authentication method.
|
||||
AuthenticationFunc func() gh.AuthConfig
|
||||
|
||||
// BrowserFunc mocks the Browser method.
|
||||
BrowserFunc func(hostname string) gh.ConfigEntry
|
||||
|
||||
// CacheDirFunc mocks the CacheDir method.
|
||||
CacheDirFunc func() string
|
||||
|
||||
// EditorFunc mocks the Editor method.
|
||||
EditorFunc func(hostname string) gh.ConfigEntry
|
||||
|
||||
// GetOrDefaultFunc mocks the GetOrDefault method.
|
||||
GetOrDefaultFunc func(hostname string, key string) o.Option[gh.ConfigEntry]
|
||||
|
||||
// GitProtocolFunc mocks the GitProtocol method.
|
||||
GitProtocolFunc func(hostname string) gh.ConfigEntry
|
||||
|
||||
// HTTPUnixSocketFunc mocks the HTTPUnixSocket method.
|
||||
HTTPUnixSocketFunc func(hostname string) gh.ConfigEntry
|
||||
|
||||
// MigrateFunc mocks the Migrate method.
|
||||
MigrateFunc func(migration gh.Migration) error
|
||||
|
||||
// PagerFunc mocks the Pager method.
|
||||
PagerFunc func(hostname string) gh.ConfigEntry
|
||||
|
||||
// PromptFunc mocks the Prompt method.
|
||||
PromptFunc func(hostname string) gh.ConfigEntry
|
||||
|
||||
// SetFunc mocks the Set method.
|
||||
SetFunc func(hostname string, key string, value string)
|
||||
|
||||
// VersionFunc mocks the Version method.
|
||||
VersionFunc func() o.Option[string]
|
||||
|
||||
// WriteFunc mocks the Write method.
|
||||
WriteFunc func() error
|
||||
|
||||
// calls tracks calls to the methods.
|
||||
calls struct {
|
||||
// Aliases holds details about calls to the Aliases method.
|
||||
Aliases []struct {
|
||||
}
|
||||
// Authentication holds details about calls to the Authentication method.
|
||||
Authentication []struct {
|
||||
}
|
||||
// Browser holds details about calls to the Browser method.
|
||||
Browser []struct {
|
||||
// Hostname is the hostname argument value.
|
||||
Hostname string
|
||||
}
|
||||
// CacheDir holds details about calls to the CacheDir method.
|
||||
CacheDir []struct {
|
||||
}
|
||||
// Editor holds details about calls to the Editor method.
|
||||
Editor []struct {
|
||||
// Hostname is the hostname argument value.
|
||||
Hostname string
|
||||
}
|
||||
// GetOrDefault holds details about calls to the GetOrDefault method.
|
||||
GetOrDefault []struct {
|
||||
// Hostname is the hostname argument value.
|
||||
Hostname string
|
||||
// Key is the key argument value.
|
||||
Key string
|
||||
}
|
||||
// GitProtocol holds details about calls to the GitProtocol method.
|
||||
GitProtocol []struct {
|
||||
// Hostname is the hostname argument value.
|
||||
Hostname string
|
||||
}
|
||||
// HTTPUnixSocket holds details about calls to the HTTPUnixSocket method.
|
||||
HTTPUnixSocket []struct {
|
||||
// Hostname is the hostname argument value.
|
||||
Hostname string
|
||||
}
|
||||
// Migrate holds details about calls to the Migrate method.
|
||||
Migrate []struct {
|
||||
// Migration is the migration argument value.
|
||||
Migration gh.Migration
|
||||
}
|
||||
// Pager holds details about calls to the Pager method.
|
||||
Pager []struct {
|
||||
// Hostname is the hostname argument value.
|
||||
Hostname string
|
||||
}
|
||||
// Prompt holds details about calls to the Prompt method.
|
||||
Prompt []struct {
|
||||
// Hostname is the hostname argument value.
|
||||
Hostname string
|
||||
}
|
||||
// Set holds details about calls to the Set method.
|
||||
Set []struct {
|
||||
// Hostname is the hostname argument value.
|
||||
Hostname string
|
||||
// Key is the key argument value.
|
||||
Key string
|
||||
// Value is the value argument value.
|
||||
Value string
|
||||
}
|
||||
// Version holds details about calls to the Version method.
|
||||
Version []struct {
|
||||
}
|
||||
// Write holds details about calls to the Write method.
|
||||
Write []struct {
|
||||
}
|
||||
}
|
||||
lockAliases sync.RWMutex
|
||||
lockAuthentication sync.RWMutex
|
||||
lockBrowser sync.RWMutex
|
||||
lockCacheDir sync.RWMutex
|
||||
lockEditor sync.RWMutex
|
||||
lockGetOrDefault sync.RWMutex
|
||||
lockGitProtocol sync.RWMutex
|
||||
lockHTTPUnixSocket sync.RWMutex
|
||||
lockMigrate sync.RWMutex
|
||||
lockPager sync.RWMutex
|
||||
lockPrompt sync.RWMutex
|
||||
lockSet sync.RWMutex
|
||||
lockVersion sync.RWMutex
|
||||
lockWrite sync.RWMutex
|
||||
}
|
||||
|
||||
// Aliases calls AliasesFunc.
|
||||
func (mock *ConfigMock) Aliases() gh.AliasConfig {
|
||||
if mock.AliasesFunc == nil {
|
||||
panic("ConfigMock.AliasesFunc: method is nil but Config.Aliases was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
}{}
|
||||
mock.lockAliases.Lock()
|
||||
mock.calls.Aliases = append(mock.calls.Aliases, callInfo)
|
||||
mock.lockAliases.Unlock()
|
||||
return mock.AliasesFunc()
|
||||
}
|
||||
|
||||
// AliasesCalls gets all the calls that were made to Aliases.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.AliasesCalls())
|
||||
func (mock *ConfigMock) AliasesCalls() []struct {
|
||||
} {
|
||||
var calls []struct {
|
||||
}
|
||||
mock.lockAliases.RLock()
|
||||
calls = mock.calls.Aliases
|
||||
mock.lockAliases.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Authentication calls AuthenticationFunc.
|
||||
func (mock *ConfigMock) Authentication() gh.AuthConfig {
|
||||
if mock.AuthenticationFunc == nil {
|
||||
panic("ConfigMock.AuthenticationFunc: method is nil but Config.Authentication was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
}{}
|
||||
mock.lockAuthentication.Lock()
|
||||
mock.calls.Authentication = append(mock.calls.Authentication, callInfo)
|
||||
mock.lockAuthentication.Unlock()
|
||||
return mock.AuthenticationFunc()
|
||||
}
|
||||
|
||||
// AuthenticationCalls gets all the calls that were made to Authentication.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.AuthenticationCalls())
|
||||
func (mock *ConfigMock) AuthenticationCalls() []struct {
|
||||
} {
|
||||
var calls []struct {
|
||||
}
|
||||
mock.lockAuthentication.RLock()
|
||||
calls = mock.calls.Authentication
|
||||
mock.lockAuthentication.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Browser calls BrowserFunc.
|
||||
func (mock *ConfigMock) Browser(hostname string) gh.ConfigEntry {
|
||||
if mock.BrowserFunc == nil {
|
||||
panic("ConfigMock.BrowserFunc: method is nil but Config.Browser was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Hostname string
|
||||
}{
|
||||
Hostname: hostname,
|
||||
}
|
||||
mock.lockBrowser.Lock()
|
||||
mock.calls.Browser = append(mock.calls.Browser, callInfo)
|
||||
mock.lockBrowser.Unlock()
|
||||
return mock.BrowserFunc(hostname)
|
||||
}
|
||||
|
||||
// BrowserCalls gets all the calls that were made to Browser.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.BrowserCalls())
|
||||
func (mock *ConfigMock) BrowserCalls() []struct {
|
||||
Hostname string
|
||||
} {
|
||||
var calls []struct {
|
||||
Hostname string
|
||||
}
|
||||
mock.lockBrowser.RLock()
|
||||
calls = mock.calls.Browser
|
||||
mock.lockBrowser.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// CacheDir calls CacheDirFunc.
|
||||
func (mock *ConfigMock) CacheDir() string {
|
||||
if mock.CacheDirFunc == nil {
|
||||
panic("ConfigMock.CacheDirFunc: method is nil but Config.CacheDir was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
}{}
|
||||
mock.lockCacheDir.Lock()
|
||||
mock.calls.CacheDir = append(mock.calls.CacheDir, callInfo)
|
||||
mock.lockCacheDir.Unlock()
|
||||
return mock.CacheDirFunc()
|
||||
}
|
||||
|
||||
// CacheDirCalls gets all the calls that were made to CacheDir.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.CacheDirCalls())
|
||||
func (mock *ConfigMock) CacheDirCalls() []struct {
|
||||
} {
|
||||
var calls []struct {
|
||||
}
|
||||
mock.lockCacheDir.RLock()
|
||||
calls = mock.calls.CacheDir
|
||||
mock.lockCacheDir.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Editor calls EditorFunc.
|
||||
func (mock *ConfigMock) Editor(hostname string) gh.ConfigEntry {
|
||||
if mock.EditorFunc == nil {
|
||||
panic("ConfigMock.EditorFunc: method is nil but Config.Editor was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Hostname string
|
||||
}{
|
||||
Hostname: hostname,
|
||||
}
|
||||
mock.lockEditor.Lock()
|
||||
mock.calls.Editor = append(mock.calls.Editor, callInfo)
|
||||
mock.lockEditor.Unlock()
|
||||
return mock.EditorFunc(hostname)
|
||||
}
|
||||
|
||||
// EditorCalls gets all the calls that were made to Editor.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.EditorCalls())
|
||||
func (mock *ConfigMock) EditorCalls() []struct {
|
||||
Hostname string
|
||||
} {
|
||||
var calls []struct {
|
||||
Hostname string
|
||||
}
|
||||
mock.lockEditor.RLock()
|
||||
calls = mock.calls.Editor
|
||||
mock.lockEditor.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// GetOrDefault calls GetOrDefaultFunc.
|
||||
func (mock *ConfigMock) GetOrDefault(hostname string, key string) o.Option[gh.ConfigEntry] {
|
||||
if mock.GetOrDefaultFunc == nil {
|
||||
panic("ConfigMock.GetOrDefaultFunc: method is nil but Config.GetOrDefault was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Hostname string
|
||||
Key string
|
||||
}{
|
||||
Hostname: hostname,
|
||||
Key: key,
|
||||
}
|
||||
mock.lockGetOrDefault.Lock()
|
||||
mock.calls.GetOrDefault = append(mock.calls.GetOrDefault, callInfo)
|
||||
mock.lockGetOrDefault.Unlock()
|
||||
return mock.GetOrDefaultFunc(hostname, key)
|
||||
}
|
||||
|
||||
// GetOrDefaultCalls gets all the calls that were made to GetOrDefault.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.GetOrDefaultCalls())
|
||||
func (mock *ConfigMock) GetOrDefaultCalls() []struct {
|
||||
Hostname string
|
||||
Key string
|
||||
} {
|
||||
var calls []struct {
|
||||
Hostname string
|
||||
Key string
|
||||
}
|
||||
mock.lockGetOrDefault.RLock()
|
||||
calls = mock.calls.GetOrDefault
|
||||
mock.lockGetOrDefault.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// GitProtocol calls GitProtocolFunc.
|
||||
func (mock *ConfigMock) GitProtocol(hostname string) gh.ConfigEntry {
|
||||
if mock.GitProtocolFunc == nil {
|
||||
panic("ConfigMock.GitProtocolFunc: method is nil but Config.GitProtocol was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Hostname string
|
||||
}{
|
||||
Hostname: hostname,
|
||||
}
|
||||
mock.lockGitProtocol.Lock()
|
||||
mock.calls.GitProtocol = append(mock.calls.GitProtocol, callInfo)
|
||||
mock.lockGitProtocol.Unlock()
|
||||
return mock.GitProtocolFunc(hostname)
|
||||
}
|
||||
|
||||
// GitProtocolCalls gets all the calls that were made to GitProtocol.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.GitProtocolCalls())
|
||||
func (mock *ConfigMock) GitProtocolCalls() []struct {
|
||||
Hostname string
|
||||
} {
|
||||
var calls []struct {
|
||||
Hostname string
|
||||
}
|
||||
mock.lockGitProtocol.RLock()
|
||||
calls = mock.calls.GitProtocol
|
||||
mock.lockGitProtocol.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// HTTPUnixSocket calls HTTPUnixSocketFunc.
|
||||
func (mock *ConfigMock) HTTPUnixSocket(hostname string) gh.ConfigEntry {
|
||||
if mock.HTTPUnixSocketFunc == nil {
|
||||
panic("ConfigMock.HTTPUnixSocketFunc: method is nil but Config.HTTPUnixSocket was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Hostname string
|
||||
}{
|
||||
Hostname: hostname,
|
||||
}
|
||||
mock.lockHTTPUnixSocket.Lock()
|
||||
mock.calls.HTTPUnixSocket = append(mock.calls.HTTPUnixSocket, callInfo)
|
||||
mock.lockHTTPUnixSocket.Unlock()
|
||||
return mock.HTTPUnixSocketFunc(hostname)
|
||||
}
|
||||
|
||||
// HTTPUnixSocketCalls gets all the calls that were made to HTTPUnixSocket.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.HTTPUnixSocketCalls())
|
||||
func (mock *ConfigMock) HTTPUnixSocketCalls() []struct {
|
||||
Hostname string
|
||||
} {
|
||||
var calls []struct {
|
||||
Hostname string
|
||||
}
|
||||
mock.lockHTTPUnixSocket.RLock()
|
||||
calls = mock.calls.HTTPUnixSocket
|
||||
mock.lockHTTPUnixSocket.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Migrate calls MigrateFunc.
|
||||
func (mock *ConfigMock) Migrate(migration gh.Migration) error {
|
||||
if mock.MigrateFunc == nil {
|
||||
panic("ConfigMock.MigrateFunc: method is nil but Config.Migrate was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Migration gh.Migration
|
||||
}{
|
||||
Migration: migration,
|
||||
}
|
||||
mock.lockMigrate.Lock()
|
||||
mock.calls.Migrate = append(mock.calls.Migrate, callInfo)
|
||||
mock.lockMigrate.Unlock()
|
||||
return mock.MigrateFunc(migration)
|
||||
}
|
||||
|
||||
// MigrateCalls gets all the calls that were made to Migrate.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.MigrateCalls())
|
||||
func (mock *ConfigMock) MigrateCalls() []struct {
|
||||
Migration gh.Migration
|
||||
} {
|
||||
var calls []struct {
|
||||
Migration gh.Migration
|
||||
}
|
||||
mock.lockMigrate.RLock()
|
||||
calls = mock.calls.Migrate
|
||||
mock.lockMigrate.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Pager calls PagerFunc.
|
||||
func (mock *ConfigMock) Pager(hostname string) gh.ConfigEntry {
|
||||
if mock.PagerFunc == nil {
|
||||
panic("ConfigMock.PagerFunc: method is nil but Config.Pager was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Hostname string
|
||||
}{
|
||||
Hostname: hostname,
|
||||
}
|
||||
mock.lockPager.Lock()
|
||||
mock.calls.Pager = append(mock.calls.Pager, callInfo)
|
||||
mock.lockPager.Unlock()
|
||||
return mock.PagerFunc(hostname)
|
||||
}
|
||||
|
||||
// PagerCalls gets all the calls that were made to Pager.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.PagerCalls())
|
||||
func (mock *ConfigMock) PagerCalls() []struct {
|
||||
Hostname string
|
||||
} {
|
||||
var calls []struct {
|
||||
Hostname string
|
||||
}
|
||||
mock.lockPager.RLock()
|
||||
calls = mock.calls.Pager
|
||||
mock.lockPager.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Prompt calls PromptFunc.
|
||||
func (mock *ConfigMock) Prompt(hostname string) gh.ConfigEntry {
|
||||
if mock.PromptFunc == nil {
|
||||
panic("ConfigMock.PromptFunc: method is nil but Config.Prompt was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Hostname string
|
||||
}{
|
||||
Hostname: hostname,
|
||||
}
|
||||
mock.lockPrompt.Lock()
|
||||
mock.calls.Prompt = append(mock.calls.Prompt, callInfo)
|
||||
mock.lockPrompt.Unlock()
|
||||
return mock.PromptFunc(hostname)
|
||||
}
|
||||
|
||||
// PromptCalls gets all the calls that were made to Prompt.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.PromptCalls())
|
||||
func (mock *ConfigMock) PromptCalls() []struct {
|
||||
Hostname string
|
||||
} {
|
||||
var calls []struct {
|
||||
Hostname string
|
||||
}
|
||||
mock.lockPrompt.RLock()
|
||||
calls = mock.calls.Prompt
|
||||
mock.lockPrompt.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Set calls SetFunc.
|
||||
func (mock *ConfigMock) Set(hostname string, key string, value string) {
|
||||
if mock.SetFunc == nil {
|
||||
panic("ConfigMock.SetFunc: method is nil but Config.Set was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Hostname string
|
||||
Key string
|
||||
Value string
|
||||
}{
|
||||
Hostname: hostname,
|
||||
Key: key,
|
||||
Value: value,
|
||||
}
|
||||
mock.lockSet.Lock()
|
||||
mock.calls.Set = append(mock.calls.Set, callInfo)
|
||||
mock.lockSet.Unlock()
|
||||
mock.SetFunc(hostname, key, value)
|
||||
}
|
||||
|
||||
// SetCalls gets all the calls that were made to Set.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.SetCalls())
|
||||
func (mock *ConfigMock) SetCalls() []struct {
|
||||
Hostname string
|
||||
Key string
|
||||
Value string
|
||||
} {
|
||||
var calls []struct {
|
||||
Hostname string
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
mock.lockSet.RLock()
|
||||
calls = mock.calls.Set
|
||||
mock.lockSet.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Version calls VersionFunc.
|
||||
func (mock *ConfigMock) Version() o.Option[string] {
|
||||
if mock.VersionFunc == nil {
|
||||
panic("ConfigMock.VersionFunc: method is nil but Config.Version was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
}{}
|
||||
mock.lockVersion.Lock()
|
||||
mock.calls.Version = append(mock.calls.Version, callInfo)
|
||||
mock.lockVersion.Unlock()
|
||||
return mock.VersionFunc()
|
||||
}
|
||||
|
||||
// VersionCalls gets all the calls that were made to Version.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.VersionCalls())
|
||||
func (mock *ConfigMock) VersionCalls() []struct {
|
||||
} {
|
||||
var calls []struct {
|
||||
}
|
||||
mock.lockVersion.RLock()
|
||||
calls = mock.calls.Version
|
||||
mock.lockVersion.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Write calls WriteFunc.
|
||||
func (mock *ConfigMock) Write() error {
|
||||
if mock.WriteFunc == nil {
|
||||
panic("ConfigMock.WriteFunc: method is nil but Config.Write was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
}{}
|
||||
mock.lockWrite.Lock()
|
||||
mock.calls.Write = append(mock.calls.Write, callInfo)
|
||||
mock.lockWrite.Unlock()
|
||||
return mock.WriteFunc()
|
||||
}
|
||||
|
||||
// WriteCalls gets all the calls that were made to Write.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedConfig.WriteCalls())
|
||||
func (mock *ConfigMock) WriteCalls() []struct {
|
||||
} {
|
||||
var calls []struct {
|
||||
}
|
||||
mock.lockWrite.RLock()
|
||||
calls = mock.calls.Write
|
||||
mock.lockWrite.RUnlock()
|
||||
return calls
|
||||
}
|
||||
150
internal/gh/mock/migration.go
Normal file
150
internal/gh/mock/migration.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
// Code generated by moq; DO NOT EDIT.
|
||||
// github.com/matryer/moq
|
||||
|
||||
package ghmock
|
||||
|
||||
import (
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
ghConfig "github.com/cli/go-gh/v2/pkg/config"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Ensure, that MigrationMock does implement gh.Migration.
|
||||
// If this is not the case, regenerate this file with moq.
|
||||
var _ gh.Migration = &MigrationMock{}
|
||||
|
||||
// MigrationMock is a mock implementation of gh.Migration.
|
||||
//
|
||||
// func TestSomethingThatUsesMigration(t *testing.T) {
|
||||
//
|
||||
// // make and configure a mocked gh.Migration
|
||||
// mockedMigration := &MigrationMock{
|
||||
// DoFunc: func(config *ghConfig.Config) error {
|
||||
// panic("mock out the Do method")
|
||||
// },
|
||||
// PostVersionFunc: func() string {
|
||||
// panic("mock out the PostVersion method")
|
||||
// },
|
||||
// PreVersionFunc: func() string {
|
||||
// panic("mock out the PreVersion method")
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// // use mockedMigration in code that requires gh.Migration
|
||||
// // and then make assertions.
|
||||
//
|
||||
// }
|
||||
type MigrationMock struct {
|
||||
// DoFunc mocks the Do method.
|
||||
DoFunc func(config *ghConfig.Config) error
|
||||
|
||||
// PostVersionFunc mocks the PostVersion method.
|
||||
PostVersionFunc func() string
|
||||
|
||||
// PreVersionFunc mocks the PreVersion method.
|
||||
PreVersionFunc func() string
|
||||
|
||||
// calls tracks calls to the methods.
|
||||
calls struct {
|
||||
// Do holds details about calls to the Do method.
|
||||
Do []struct {
|
||||
// Config is the config argument value.
|
||||
Config *ghConfig.Config
|
||||
}
|
||||
// PostVersion holds details about calls to the PostVersion method.
|
||||
PostVersion []struct {
|
||||
}
|
||||
// PreVersion holds details about calls to the PreVersion method.
|
||||
PreVersion []struct {
|
||||
}
|
||||
}
|
||||
lockDo sync.RWMutex
|
||||
lockPostVersion sync.RWMutex
|
||||
lockPreVersion sync.RWMutex
|
||||
}
|
||||
|
||||
// Do calls DoFunc.
|
||||
func (mock *MigrationMock) Do(config *ghConfig.Config) error {
|
||||
if mock.DoFunc == nil {
|
||||
panic("MigrationMock.DoFunc: method is nil but Migration.Do was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Config *ghConfig.Config
|
||||
}{
|
||||
Config: config,
|
||||
}
|
||||
mock.lockDo.Lock()
|
||||
mock.calls.Do = append(mock.calls.Do, callInfo)
|
||||
mock.lockDo.Unlock()
|
||||
return mock.DoFunc(config)
|
||||
}
|
||||
|
||||
// DoCalls gets all the calls that were made to Do.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedMigration.DoCalls())
|
||||
func (mock *MigrationMock) DoCalls() []struct {
|
||||
Config *ghConfig.Config
|
||||
} {
|
||||
var calls []struct {
|
||||
Config *ghConfig.Config
|
||||
}
|
||||
mock.lockDo.RLock()
|
||||
calls = mock.calls.Do
|
||||
mock.lockDo.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// PostVersion calls PostVersionFunc.
|
||||
func (mock *MigrationMock) PostVersion() string {
|
||||
if mock.PostVersionFunc == nil {
|
||||
panic("MigrationMock.PostVersionFunc: method is nil but Migration.PostVersion was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
}{}
|
||||
mock.lockPostVersion.Lock()
|
||||
mock.calls.PostVersion = append(mock.calls.PostVersion, callInfo)
|
||||
mock.lockPostVersion.Unlock()
|
||||
return mock.PostVersionFunc()
|
||||
}
|
||||
|
||||
// PostVersionCalls gets all the calls that were made to PostVersion.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedMigration.PostVersionCalls())
|
||||
func (mock *MigrationMock) PostVersionCalls() []struct {
|
||||
} {
|
||||
var calls []struct {
|
||||
}
|
||||
mock.lockPostVersion.RLock()
|
||||
calls = mock.calls.PostVersion
|
||||
mock.lockPostVersion.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// PreVersion calls PreVersionFunc.
|
||||
func (mock *MigrationMock) PreVersion() string {
|
||||
if mock.PreVersionFunc == nil {
|
||||
panic("MigrationMock.PreVersionFunc: method is nil but Migration.PreVersion was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
}{}
|
||||
mock.lockPreVersion.Lock()
|
||||
mock.calls.PreVersion = append(mock.calls.PreVersion, callInfo)
|
||||
mock.lockPreVersion.Unlock()
|
||||
return mock.PreVersionFunc()
|
||||
}
|
||||
|
||||
// PreVersionCalls gets all the calls that were made to PreVersion.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedMigration.PreVersionCalls())
|
||||
func (mock *MigrationMock) PreVersionCalls() []struct {
|
||||
} {
|
||||
var calls []struct {
|
||||
}
|
||||
mock.lockPreVersion.RLock()
|
||||
calls = mock.calls.PreVersion
|
||||
mock.lockPreVersion.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
|
@ -2,11 +2,14 @@
|
|||
package keyring
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("secret not found in keyring")
|
||||
|
||||
type TimeoutError struct {
|
||||
message string
|
||||
}
|
||||
|
|
@ -46,6 +49,9 @@ func Get(service, user string) (string, error) {
|
|||
}()
|
||||
select {
|
||||
case res := <-ch:
|
||||
if errors.Is(res.err, keyring.ErrNotFound) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return res.val, res.err
|
||||
case <-time.After(3 * time.Second):
|
||||
return "", &TimeoutError{"timeout while trying to get secret from keyring"}
|
||||
|
|
@ -66,3 +72,11 @@ func Delete(service, user string) error {
|
|||
return &TimeoutError{"timeout while trying to delete secret from keyring"}
|
||||
}
|
||||
}
|
||||
|
||||
func MockInit() {
|
||||
keyring.MockInit()
|
||||
}
|
||||
|
||||
func MockInitWithError(err error) {
|
||||
keyring.MockInitWithError(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(s string, strings1 []string, strings2 []string) ([]int, error) {
|
||||
// MultiSelectFunc: func(prompt string, defaults []string, options []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(s string, strings1 []string, strings2 []string) ([]int, error)
|
||||
MultiSelectFunc func(prompt string, defaults []string, options []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 {
|
||||
// S is the s argument value.
|
||||
S string
|
||||
// Strings1 is the strings1 argument value.
|
||||
Strings1 []string
|
||||
// Strings2 is the strings2 argument value.
|
||||
Strings2 []string
|
||||
// Prompt is the prompt argument value.
|
||||
Prompt string
|
||||
// Defaults is the defaults argument value.
|
||||
Defaults []string
|
||||
// Options is the options argument value.
|
||||
Options []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(s string, strings1 []string, strings2 []string) ([]int, error) {
|
||||
func (mock *PrompterMock) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) {
|
||||
if mock.MultiSelectFunc == nil {
|
||||
panic("PrompterMock.MultiSelectFunc: method is nil but Prompter.MultiSelect was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
S string
|
||||
Strings1 []string
|
||||
Strings2 []string
|
||||
Prompt string
|
||||
Defaults []string
|
||||
Options []string
|
||||
}{
|
||||
S: s,
|
||||
Strings1: strings1,
|
||||
Strings2: strings2,
|
||||
Prompt: prompt,
|
||||
Defaults: defaults,
|
||||
Options: options,
|
||||
}
|
||||
mock.lockMultiSelect.Lock()
|
||||
mock.calls.MultiSelect = append(mock.calls.MultiSelect, callInfo)
|
||||
mock.lockMultiSelect.Unlock()
|
||||
return mock.MultiSelectFunc(s, strings1, strings2)
|
||||
return mock.MultiSelectFunc(prompt, defaults, options)
|
||||
}
|
||||
|
||||
// MultiSelectCalls gets all the calls that were made to MultiSelect.
|
||||
|
|
@ -372,14 +372,14 @@ func (mock *PrompterMock) MultiSelect(s string, strings1 []string, strings2 []st
|
|||
//
|
||||
// len(mockedPrompter.MultiSelectCalls())
|
||||
func (mock *PrompterMock) MultiSelectCalls() []struct {
|
||||
S string
|
||||
Strings1 []string
|
||||
Strings2 []string
|
||||
Prompt string
|
||||
Defaults []string
|
||||
Options []string
|
||||
} {
|
||||
var calls []struct {
|
||||
S string
|
||||
Strings1 []string
|
||||
Strings2 []string
|
||||
Prompt string
|
||||
Defaults []string
|
||||
Options []string
|
||||
}
|
||||
mock.lockMultiSelect.RLock()
|
||||
calls = mock.calls.MultiSelect
|
||||
|
|
|
|||
|
|
@ -138,13 +138,13 @@ func IndexFor(options []string, answer string) (int, error) {
|
|||
return ix, nil
|
||||
}
|
||||
}
|
||||
return -1, NoSuchAnswerErr(answer)
|
||||
return -1, NoSuchAnswerErr(answer, options)
|
||||
}
|
||||
|
||||
func NoSuchPromptErr(prompt string) error {
|
||||
return fmt.Errorf("no such prompt '%s'", prompt)
|
||||
}
|
||||
|
||||
func NoSuchAnswerErr(answer string) error {
|
||||
return fmt.Errorf("no such answer '%s'", answer)
|
||||
func NoSuchAnswerErr(answer string, options []string) error {
|
||||
return fmt.Errorf("no such answer '%s' in [%s]", answer, strings.Join(options, ", "))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package tableprinter
|
||||
|
||||
import (
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -12,42 +13,90 @@ import (
|
|||
type TablePrinter struct {
|
||||
tableprinter.TablePrinter
|
||||
isTTY bool
|
||||
cs *iostreams.ColorScheme
|
||||
}
|
||||
|
||||
func (t *TablePrinter) HeaderRow(columns ...string) {
|
||||
if !t.isTTY {
|
||||
return
|
||||
}
|
||||
for _, col := range columns {
|
||||
t.AddField(strings.ToUpper(col))
|
||||
}
|
||||
t.EndRow()
|
||||
// IsTTY gets whether the TablePrinter will render to a terminal.
|
||||
func (t *TablePrinter) IsTTY() bool {
|
||||
return t.isTTY
|
||||
}
|
||||
|
||||
// In tty mode display the fuzzy time difference between now and t.
|
||||
// In nontty mode just display t with the time.RFC3339 format.
|
||||
// AddTimeField in TTY mode displays the fuzzy time difference between now and t.
|
||||
// In non-TTY mode it just displays t with the time.RFC3339 format.
|
||||
func (tp *TablePrinter) AddTimeField(now, t time.Time, c func(string) string) {
|
||||
tf := t.Format(time.RFC3339)
|
||||
var tf string
|
||||
if tp.isTTY {
|
||||
tf = text.FuzzyAgo(now, t)
|
||||
} else {
|
||||
tf = t.Format(time.RFC3339)
|
||||
}
|
||||
tp.AddField(tf, tableprinter.WithColor(c))
|
||||
tp.AddField(tf, WithColor(c))
|
||||
}
|
||||
|
||||
var (
|
||||
WithTruncate = tableprinter.WithTruncate
|
||||
WithColor = tableprinter.WithColor
|
||||
WithPadding = tableprinter.WithPadding
|
||||
WithTruncate = tableprinter.WithTruncate
|
||||
)
|
||||
|
||||
func New(ios *iostreams.IOStreams) *TablePrinter {
|
||||
type headerOption struct {
|
||||
columns []string
|
||||
}
|
||||
|
||||
// New creates a TablePrinter from an IOStreams.
|
||||
func New(ios *iostreams.IOStreams, headers headerOption) *TablePrinter {
|
||||
maxWidth := 80
|
||||
isTTY := ios.IsStdoutTTY()
|
||||
if isTTY {
|
||||
maxWidth = ios.TerminalWidth()
|
||||
}
|
||||
tp := tableprinter.New(ios.Out, isTTY, maxWidth)
|
||||
return &TablePrinter{
|
||||
TablePrinter: tp,
|
||||
isTTY: isTTY,
|
||||
}
|
||||
|
||||
return NewWithWriter(ios.Out, isTTY, maxWidth, ios.ColorScheme(), headers)
|
||||
}
|
||||
|
||||
// NewWithWriter creates a TablePrinter from a Writer, whether the output is a terminal, the terminal width, and more.
|
||||
func NewWithWriter(w io.Writer, isTTY bool, maxWidth int, cs *iostreams.ColorScheme, headers headerOption) *TablePrinter {
|
||||
tp := &TablePrinter{
|
||||
TablePrinter: tableprinter.New(w, isTTY, maxWidth),
|
||||
isTTY: isTTY,
|
||||
cs: cs,
|
||||
}
|
||||
|
||||
if isTTY && len(headers.columns) > 0 {
|
||||
// Make sure all headers are uppercase, taking a copy of the headers to avoid modifying the original slice.
|
||||
upperCasedHeaders := make([]string, len(headers.columns))
|
||||
for i := range headers.columns {
|
||||
upperCasedHeaders[i] = strings.ToUpper(headers.columns[i])
|
||||
}
|
||||
|
||||
// Make sure all header columns are padded - even the last one. Previously, the last header column
|
||||
// was not padded. In tests cs.Enabled() is false which allows us to avoid having to fix up
|
||||
// numerous tests that verify header padding.
|
||||
var paddingFunc func(int, string) string
|
||||
if cs.Enabled() {
|
||||
paddingFunc = text.PadRight
|
||||
}
|
||||
|
||||
tp.AddHeader(
|
||||
upperCasedHeaders,
|
||||
WithPadding(paddingFunc),
|
||||
WithColor(cs.LightGrayUnderline),
|
||||
)
|
||||
}
|
||||
|
||||
return tp
|
||||
}
|
||||
|
||||
// WithHeader defines the column names for a table.
|
||||
// Panics if columns is nil or empty.
|
||||
func WithHeader(columns ...string) headerOption {
|
||||
if len(columns) == 0 {
|
||||
panic("must define header columns")
|
||||
}
|
||||
return headerOption{columns}
|
||||
}
|
||||
|
||||
// NoHeader disable printing or checking for a table header.
|
||||
//
|
||||
// Deprecated: use WithHeader unless required otherwise.
|
||||
var NoHeader = headerOption{}
|
||||
|
|
|
|||
22
internal/tableprinter/table_printer_test.go
Normal file
22
internal/tableprinter/table_printer_test.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package tableprinter_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/tableprinter"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHeadersAreNotMutated(t *testing.T) {
|
||||
// Given a TTY environment so that headers are included in the table
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
ios.SetStdoutTTY(true)
|
||||
|
||||
// When creating a new table printer
|
||||
headers := []string{"one", "two", "three"}
|
||||
_ = tableprinter.New(ios, tableprinter.WithHeader(headers...))
|
||||
|
||||
// The provided headers should not be mutated
|
||||
require.Equal(t, []string{"one", "two", "three"}, headers)
|
||||
}
|
||||
|
|
@ -2,8 +2,10 @@ package text
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -77,3 +79,68 @@ func DisplayURL(urlStr string) string {
|
|||
func RemoveDiacritics(value string) string {
|
||||
return text.RemoveDiacritics(value)
|
||||
}
|
||||
|
||||
func PadRight(maxWidth int, s string) string {
|
||||
return text.PadRight(maxWidth, s)
|
||||
}
|
||||
|
||||
// FormatSlice concatenates elements of the given string slice into a
|
||||
// well-formatted, possibly multiline, string with specific line length limit.
|
||||
// Elements can be optionally surrounded by custom strings (e.g., quotes or
|
||||
// brackets). If the lineLength argument is non-positive, no line length limit
|
||||
// will be applied.
|
||||
func FormatSlice(values []string, lineLength uint, indent uint, prependWith string, appendWith string, sort bool) string {
|
||||
if lineLength <= 0 {
|
||||
lineLength = math.MaxInt
|
||||
}
|
||||
|
||||
sortedValues := values
|
||||
if sort {
|
||||
sortedValues = slices.Clone(values)
|
||||
slices.Sort(sortedValues)
|
||||
}
|
||||
|
||||
pre := strings.Repeat(" ", int(indent))
|
||||
if len(sortedValues) == 0 {
|
||||
return pre
|
||||
} else if len(sortedValues) == 1 {
|
||||
return pre + sortedValues[0]
|
||||
}
|
||||
|
||||
builder := strings.Builder{}
|
||||
currentLineLength := 0
|
||||
sep := ","
|
||||
ws := " "
|
||||
|
||||
for i := 0; i < len(sortedValues); i++ {
|
||||
v := prependWith + sortedValues[i] + appendWith
|
||||
isLast := i == -1+len(sortedValues)
|
||||
|
||||
if currentLineLength == 0 {
|
||||
builder.WriteString(pre)
|
||||
builder.WriteString(v)
|
||||
currentLineLength += len(v)
|
||||
if !isLast {
|
||||
builder.WriteString(sep)
|
||||
currentLineLength += len(sep)
|
||||
}
|
||||
} else {
|
||||
if !isLast && currentLineLength+len(ws)+len(v)+len(sep) > int(lineLength) ||
|
||||
isLast && currentLineLength+len(ws)+len(v) > int(lineLength) {
|
||||
currentLineLength = 0
|
||||
builder.WriteString("\n")
|
||||
i--
|
||||
continue
|
||||
}
|
||||
|
||||
builder.WriteString(ws)
|
||||
builder.WriteString(v)
|
||||
currentLineLength += len(ws) + len(v)
|
||||
if !isLast {
|
||||
builder.WriteString(sep)
|
||||
currentLineLength += len(sep)
|
||||
}
|
||||
}
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,3 +54,95 @@ func TestFuzzyAgoAbbr(t *testing.T) {
|
|||
assert.Equal(t, expected, fuzzy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSlice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values []string
|
||||
indent uint
|
||||
lineLength uint
|
||||
prependWith string
|
||||
appendWith string
|
||||
sort bool
|
||||
wants string
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
lineLength: 10,
|
||||
values: []string{},
|
||||
wants: "",
|
||||
},
|
||||
{
|
||||
name: "empty with indent",
|
||||
lineLength: 10,
|
||||
indent: 2,
|
||||
values: []string{},
|
||||
wants: " ",
|
||||
},
|
||||
{
|
||||
name: "single",
|
||||
lineLength: 10,
|
||||
values: []string{"foo"},
|
||||
wants: "foo",
|
||||
},
|
||||
{
|
||||
name: "single with indent",
|
||||
lineLength: 10,
|
||||
indent: 2,
|
||||
values: []string{"foo"},
|
||||
wants: " foo",
|
||||
},
|
||||
{
|
||||
name: "long single with indent",
|
||||
lineLength: 10,
|
||||
indent: 2,
|
||||
values: []string{"some-long-value"},
|
||||
wants: " some-long-value",
|
||||
},
|
||||
{
|
||||
name: "exact line length",
|
||||
lineLength: 4,
|
||||
values: []string{"a", "b"},
|
||||
wants: "a, b",
|
||||
},
|
||||
{
|
||||
name: "values longer than line length",
|
||||
lineLength: 4,
|
||||
values: []string{"long-value", "long-value"},
|
||||
wants: "long-value,\nlong-value",
|
||||
},
|
||||
{
|
||||
name: "zero line length (no wrapping expected)",
|
||||
lineLength: 0,
|
||||
values: []string{"foo", "bar"},
|
||||
wants: "foo, bar",
|
||||
},
|
||||
{
|
||||
name: "simple",
|
||||
lineLength: 10,
|
||||
values: []string{"foo", "bar", "baz", "foo", "bar", "baz"},
|
||||
wants: "foo, bar,\nbaz, foo,\nbar, baz",
|
||||
},
|
||||
{
|
||||
name: "simple, surrounded",
|
||||
lineLength: 13,
|
||||
prependWith: "<",
|
||||
appendWith: ">",
|
||||
values: []string{"foo", "bar", "baz", "foo", "bar", "baz"},
|
||||
wants: "<foo>, <bar>,\n<baz>, <foo>,\n<bar>, <baz>",
|
||||
},
|
||||
{
|
||||
name: "sort",
|
||||
lineLength: 99,
|
||||
sort: true,
|
||||
values: []string{"c", "b", "a"},
|
||||
wants: "a, b, c",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.wants, FormatSlice(tt.values, tt.lineLength, tt.indent, tt.prependWith, tt.appendWith, tt.sort))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,36 +26,36 @@ func actionsExplainer(cs *iostreams.ColorScheme) string {
|
|||
header := cs.Bold("Welcome to GitHub Actions on the command line.")
|
||||
runHeader := cs.Bold("Interacting with workflow runs")
|
||||
workflowHeader := cs.Bold("Interacting with workflow files")
|
||||
cacheHeader := cs.Bold("Interacting with the Actions cache")
|
||||
cacheHeader := cs.Bold("Interacting with the GitHub Actions cache")
|
||||
|
||||
return heredoc.Docf(`
|
||||
%s
|
||||
%[2]s
|
||||
|
||||
GitHub CLI integrates with Actions to help you manage runs and workflows.
|
||||
GitHub CLI integrates with GitHub Actions to help you manage runs and workflows.
|
||||
|
||||
%s
|
||||
%[3]s
|
||||
gh run list: List recent workflow runs
|
||||
gh run view: View details for a workflow run or one of its jobs
|
||||
gh run watch: Watch a workflow run while it executes
|
||||
gh run rerun: Rerun a failed workflow run
|
||||
gh run download: Download artifacts generated by runs
|
||||
|
||||
To see more help, run 'gh help run <subcommand>'
|
||||
To see more help, run %[1]sgh help run <subcommand>%[1]s
|
||||
|
||||
%s
|
||||
%[4]s
|
||||
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
|
||||
gh workflow run: Trigger a workflow_dispatch run for a workflow file
|
||||
|
||||
To see more help, run 'gh help workflow <subcommand>'
|
||||
To see more help, run %[1]sgh help workflow <subcommand>%[1]s
|
||||
|
||||
%s
|
||||
gh cache list: List all the caches saved in Actions for a repository
|
||||
gh cache delete: Delete one or all saved caches in Actions for a repository
|
||||
%[5]s
|
||||
gh cache list: List all the caches saved in GitHub Actions for a repository
|
||||
gh cache delete: Delete one or all saved caches in GitHub Actions for a repository
|
||||
|
||||
To see more help, run 'gh help cache <subcommand>'
|
||||
To see more help, run %[1]sgh help cache <subcommand>%[1]s
|
||||
|
||||
`, header, runHeader, workflowHeader, cacheHeader)
|
||||
`, "`", header, runHeader, workflowHeader, cacheHeader)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,11 +14,11 @@ func NewCmdAlias(f *cmdutil.Factory) *cobra.Command {
|
|||
cmd := &cobra.Command{
|
||||
Use: "alias <command>",
|
||||
Short: "Create command shortcuts",
|
||||
Long: heredoc.Doc(`
|
||||
Long: heredoc.Docf(`
|
||||
Aliases can be used to make shortcuts for gh commands or to compose multiple commands.
|
||||
|
||||
Run "gh help alias set" to learn more.
|
||||
`),
|
||||
Run %[1]sgh help alias set%[1]s to learn more.
|
||||
`, "`"),
|
||||
}
|
||||
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
|
|
|||
|
|
@ -2,18 +2,20 @@ package delete
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type DeleteOptions struct {
|
||||
Config func() (config.Config, error)
|
||||
Config func() (gh.Config, error)
|
||||
IO *iostreams.IOStreams
|
||||
|
||||
Name string
|
||||
All bool
|
||||
}
|
||||
|
||||
func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
|
||||
|
|
@ -23,12 +25,19 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
|
|||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <alias>",
|
||||
Short: "Delete an alias",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Use: "delete {<alias> | --all}",
|
||||
Short: "Delete set aliases",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Name = args[0]
|
||||
|
||||
if len(args) == 0 && !opts.All {
|
||||
return cmdutil.FlagErrorf("specify an alias to delete or `--all`")
|
||||
}
|
||||
if len(args) > 0 && opts.All {
|
||||
return cmdutil.FlagErrorf("cannot use `--all` with alias name")
|
||||
}
|
||||
if len(args) > 0 {
|
||||
opts.Name = args[0]
|
||||
}
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
|
@ -36,6 +45,8 @@ func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Co
|
|||
},
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&opts.All, "all", false, "Delete all aliases")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
@ -47,25 +58,40 @@ func deleteRun(opts *DeleteOptions) error {
|
|||
|
||||
aliasCfg := cfg.Aliases()
|
||||
|
||||
expansion, err := aliasCfg.Get(opts.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no such alias %s", opts.Name)
|
||||
|
||||
aliases := make(map[string]string)
|
||||
if opts.All {
|
||||
aliases = aliasCfg.All()
|
||||
if len(aliases) == 0 {
|
||||
return cmdutil.NewNoResultsError("no aliases configured")
|
||||
}
|
||||
} else {
|
||||
expansion, err := aliasCfg.Get(opts.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("no such alias %s", opts.Name)
|
||||
}
|
||||
aliases[opts.Name] = expansion
|
||||
}
|
||||
|
||||
err = aliasCfg.Delete(opts.Name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete alias %s: %w", opts.Name, err)
|
||||
for name := range aliases {
|
||||
if err := aliasCfg.Delete(name); err != nil {
|
||||
return fmt.Errorf("failed to delete alias %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
err = cfg.Write()
|
||||
if err != nil {
|
||||
if err := cfg.Write(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
cs := opts.IO.ColorScheme()
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Deleted alias %s; was %s\n", cs.SuccessIconWithColor(cs.Red), opts.Name, expansion)
|
||||
keys := make([]string, 0, len(aliases))
|
||||
for k := range aliases {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Deleted alias %s; was %s\n", cs.SuccessIconWithColor(cs.Red), k, aliases[k])
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -7,82 +7,179 @@ import (
|
|||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAliasDelete(t *testing.T) {
|
||||
_ = config.StubWriteConfig(t)
|
||||
|
||||
func TestNewCmdDelete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config string
|
||||
cli string
|
||||
isTTY bool
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
wantErr string
|
||||
name string
|
||||
input string
|
||||
output DeleteOptions
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "no aliases",
|
||||
config: "",
|
||||
cli: "co",
|
||||
isTTY: true,
|
||||
wantStdout: "",
|
||||
wantStderr: "",
|
||||
wantErr: "no such alias co",
|
||||
name: "no arguments",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
errMsg: "specify an alias to delete or `--all`",
|
||||
},
|
||||
{
|
||||
name: "delete one",
|
||||
name: "specified alias",
|
||||
input: "co",
|
||||
output: DeleteOptions{
|
||||
Name: "co",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all flag",
|
||||
input: "--all",
|
||||
output: DeleteOptions{
|
||||
All: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "specified alias and all flag",
|
||||
input: "co --all",
|
||||
wantErr: true,
|
||||
errMsg: "cannot use `--all` with alias name",
|
||||
},
|
||||
{
|
||||
name: "too many arguments",
|
||||
input: "il co",
|
||||
wantErr: true,
|
||||
errMsg: "accepts at most 1 arg(s), received 2",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: ios,
|
||||
}
|
||||
argv, err := shlex.Split(tt.input)
|
||||
assert.NoError(t, err)
|
||||
var gotOpts *DeleteOptions
|
||||
cmd := NewCmdDelete(f, func(opts *DeleteOptions) 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.wantErr {
|
||||
assert.EqualError(t, err, tt.errMsg)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.output.Name, gotOpts.Name)
|
||||
assert.Equal(t, tt.output.All, gotOpts.All)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config string
|
||||
isTTY bool
|
||||
opts *DeleteOptions
|
||||
wantAliases map[string]string
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
wantErrMsg string
|
||||
}{
|
||||
{
|
||||
name: "delete alias",
|
||||
config: heredoc.Doc(`
|
||||
aliases:
|
||||
il: issue list
|
||||
co: pr checkout
|
||||
`),
|
||||
cli: "co",
|
||||
isTTY: true,
|
||||
wantStdout: "",
|
||||
isTTY: true,
|
||||
opts: &DeleteOptions{
|
||||
Name: "co",
|
||||
All: false,
|
||||
},
|
||||
wantAliases: map[string]string{
|
||||
"il": "issue list",
|
||||
},
|
||||
wantStderr: "✓ Deleted alias co; was pr checkout\n",
|
||||
},
|
||||
{
|
||||
name: "delete all aliases",
|
||||
config: heredoc.Doc(`
|
||||
aliases:
|
||||
il: issue list
|
||||
co: pr checkout
|
||||
`),
|
||||
isTTY: true,
|
||||
opts: &DeleteOptions{
|
||||
All: true,
|
||||
},
|
||||
wantAliases: map[string]string{},
|
||||
wantStderr: "✓ Deleted alias co; was pr checkout\n✓ Deleted alias il; was issue list\n",
|
||||
},
|
||||
{
|
||||
name: "delete alias that does not exist",
|
||||
config: heredoc.Doc(`
|
||||
aliases:
|
||||
il: issue list
|
||||
co: pr checkout
|
||||
`),
|
||||
isTTY: true,
|
||||
opts: &DeleteOptions{
|
||||
Name: "unknown",
|
||||
},
|
||||
wantAliases: map[string]string{
|
||||
"il": "issue list",
|
||||
"co": "pr checkout",
|
||||
},
|
||||
wantErrMsg: "no such alias unknown",
|
||||
},
|
||||
{
|
||||
name: "delete all aliases when none exist",
|
||||
isTTY: true,
|
||||
opts: &DeleteOptions{
|
||||
All: true,
|
||||
},
|
||||
wantAliases: map[string]string{},
|
||||
wantErrMsg: "no aliases configured",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := config.NewFromString(tt.config)
|
||||
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
ios.SetStdoutTTY(tt.isTTY)
|
||||
ios.SetStdinTTY(tt.isTTY)
|
||||
ios.SetStdoutTTY(tt.isTTY)
|
||||
ios.SetStderrTTY(tt.isTTY)
|
||||
tt.opts.IO = ios
|
||||
|
||||
factory := &cmdutil.Factory{
|
||||
IOStreams: ios,
|
||||
Config: func() (config.Config, error) {
|
||||
return cfg, nil
|
||||
},
|
||||
cfg := config.NewFromString(tt.config)
|
||||
tt.opts.Config = func() (gh.Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
cmd := NewCmdDelete(factory, nil)
|
||||
|
||||
argv, err := shlex.Split(tt.cli)
|
||||
require.NoError(t, err)
|
||||
cmd.SetArgs(argv)
|
||||
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantErr != "" {
|
||||
assert.EqualError(t, err, tt.wantErr)
|
||||
return
|
||||
err := deleteRun(tt.opts)
|
||||
if tt.wantErrMsg != "" {
|
||||
assert.EqualError(t, err, tt.wantErrMsg)
|
||||
writeCalls := cfg.WriteCalls()
|
||||
assert.Equal(t, 0, len(writeCalls))
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
writeCalls := cfg.WriteCalls()
|
||||
assert.Equal(t, 1, len(writeCalls))
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.wantStdout, stdout.String())
|
||||
assert.Equal(t, tt.wantStderr, stderr.String())
|
||||
assert.Equal(t, tt.wantAliases, cfg.Aliases().All())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
|
|
@ -15,7 +15,7 @@ import (
|
|||
)
|
||||
|
||||
type ImportOptions struct {
|
||||
Config func() (config.Config, error)
|
||||
Config func() (gh.Config, error)
|
||||
IO *iostreams.IOStreams
|
||||
|
||||
Filename string
|
||||
|
|
@ -34,7 +34,7 @@ func NewCmdImport(f *cmdutil.Factory, runF func(*ImportOptions) error) *cobra.Co
|
|||
cmd := &cobra.Command{
|
||||
Use: "import [<filename> | -]",
|
||||
Short: "Import aliases from a YAML file",
|
||||
Long: heredoc.Doc(`
|
||||
Long: heredoc.Docf(`
|
||||
Import aliases from the contents of a YAML file.
|
||||
|
||||
Aliases should be defined as a map in YAML, where the keys represent aliases and
|
||||
|
|
@ -47,12 +47,12 @@ func NewCmdImport(f *cmdutil.Factory, runF func(*ImportOptions) error) *cobra.Co
|
|||
issue list
|
||||
--label=enhancement
|
||||
|
||||
Use "-" to read aliases (in YAML format) from standard input.
|
||||
Use %[1]s-%[1]s 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
|
||||
The output from %[1]sgh alias list%[1]s 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.
|
||||
`),
|
||||
another. Run %[1]sgh help alias list%[1]s to learn more.
|
||||
`, "`"),
|
||||
Example: heredoc.Doc(`
|
||||
# Import aliases from a file
|
||||
$ gh alias import aliases.yml
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
|
|
@ -309,7 +310,7 @@ func TestImportRun(t *testing.T) {
|
|||
|
||||
readConfigs := config.StubWriteConfig(t)
|
||||
cfg := config.NewFromString(tt.initConfig)
|
||||
tt.opts.Config = func() (config.Config, error) {
|
||||
tt.opts.Config = func() (gh.Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ package list
|
|||
|
||||
import (
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
|
|
@ -10,7 +10,7 @@ import (
|
|||
)
|
||||
|
||||
type ListOptions struct {
|
||||
Config func() (config.Config, error)
|
||||
Config func() (gh.Config, error)
|
||||
IO *iostreams.IOStreams
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -66,7 +67,7 @@ func TestAliasList(t *testing.T) {
|
|||
|
||||
factory := &cmdutil.Factory{
|
||||
IOStreams: ios,
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return cfg, nil
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
|
|
@ -14,7 +14,7 @@ import (
|
|||
)
|
||||
|
||||
type SetOptions struct {
|
||||
Config func() (config.Config, error)
|
||||
Config func() (gh.Config, error)
|
||||
IO *iostreams.IOStreams
|
||||
|
||||
Name string
|
||||
|
|
@ -35,21 +35,21 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
|
|||
cmd := &cobra.Command{
|
||||
Use: "set <alias> <expansion>",
|
||||
Short: "Create a shortcut for a gh command",
|
||||
Long: heredoc.Doc(`
|
||||
Long: heredoc.Docf(`
|
||||
Define a word that will expand to a full gh command when invoked.
|
||||
|
||||
The expansion may specify additional arguments and flags. If the expansion includes
|
||||
positional placeholders such as "$1", extra arguments that follow the alias will be
|
||||
positional placeholders such as %[1]s$1%[1]s, extra arguments that follow the alias will be
|
||||
inserted appropriately. Otherwise, extra arguments will be appended to the expanded
|
||||
command.
|
||||
|
||||
Use "-" as expansion argument to read the expansion string from standard input. This
|
||||
Use %[1]s-%[1]s as expansion argument to read the expansion string from standard input. This
|
||||
is useful to avoid quoting issues when defining expansions.
|
||||
|
||||
If the expansion starts with "!" or if "--shell" was given, the expansion is a shell
|
||||
expression that will be evaluated through the "sh" interpreter when the alias is
|
||||
If the expansion starts with %[1]s!%[1]s or if %[1]s--shell%[1]s was given, the expansion is a shell
|
||||
expression that will be evaluated through the %[1]ssh%[1]s interpreter when the alias is
|
||||
invoked. This allows for chaining multiple commands via piping and redirection.
|
||||
`),
|
||||
`, "`"),
|
||||
Example: heredoc.Doc(`
|
||||
# note: Command Prompt on Windows requires using double quotes for arguments
|
||||
$ gh alias set pv 'pr view'
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/pkg/cmd/alias/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
|
|
@ -284,7 +285,7 @@ func TestSetRun(t *testing.T) {
|
|||
cfg.WriteFunc = func() error {
|
||||
return nil
|
||||
}
|
||||
tt.opts.Config = func() (config.Config, error) {
|
||||
tt.opts.Config = func() (gh.Config, error) {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import (
|
|||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/cmd/factory"
|
||||
|
|
@ -29,11 +29,15 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
ttyIndent = " "
|
||||
)
|
||||
|
||||
type ApiOptions struct {
|
||||
AppVersion string
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
Branch func() (string, error)
|
||||
Config func() (config.Config, error)
|
||||
Config func() (gh.Config, error)
|
||||
HttpClient func() (*http.Client, error)
|
||||
IO *iostreams.IOStreams
|
||||
|
||||
|
|
@ -48,6 +52,7 @@ type ApiOptions struct {
|
|||
Previews []string
|
||||
ShowResponseHeaders bool
|
||||
Paginate bool
|
||||
Slurp bool
|
||||
Silent bool
|
||||
Template string
|
||||
CacheTTL time.Duration
|
||||
|
|
@ -71,50 +76,52 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
|
|||
Makes an authenticated HTTP request to the GitHub API and prints the response.
|
||||
|
||||
The endpoint argument should either be a path of a GitHub API v3 endpoint, or
|
||||
"graphql" to access the GitHub API v4.
|
||||
%[1]sgraphql%[1]s to access the GitHub API v4.
|
||||
|
||||
Placeholder values "{owner}", "{repo}", and "{branch}" in the endpoint
|
||||
Placeholder values %[1]s{owner}%[1]s, %[1]s{repo}%[1]s, and %[1]s{branch}%[1]s in the endpoint
|
||||
argument will get replaced with values from the repository of the current
|
||||
directory or the repository specified in the GH_REPO environment variable.
|
||||
directory or the repository specified in the %[1]sGH_REPO%[1]s environment variable.
|
||||
Note that in some shells, for example PowerShell, you may need to enclose
|
||||
any value that contains "{...}" in quotes to prevent the shell from
|
||||
any value that contains %[1]s{...}%[1]s in quotes to prevent the shell from
|
||||
applying special meaning to curly braces.
|
||||
|
||||
The default HTTP request method is "GET" normally and "POST" if any parameters
|
||||
The default HTTP request method is %[1]sGET%[1]s normally and %[1]sPOST%[1]s if any parameters
|
||||
were added. Override the method with %[1]s--method%[1]s.
|
||||
|
||||
Pass one or more %[1]s-f/--raw-field%[1]s values in "key=value" format to add static string
|
||||
Pass one or more %[1]s-f/--raw-field%[1]s values in %[1]skey=value%[1]s format to add static string
|
||||
parameters to the request payload. To add non-string or placeholder-determined values, see
|
||||
%[1]s--field%[1]s below. Note that adding request parameters will automatically switch the
|
||||
request method to POST. To send the parameters as a GET query string instead, use
|
||||
%[1]s-F/--field%[1]s below. Note that adding request parameters will automatically switch the
|
||||
request method to %[1]sPOST%[1]s. To send the parameters as a %[1]sGET%[1]s query string instead, use
|
||||
%[1]s--method GET%[1]s.
|
||||
|
||||
The %[1]s-F/--field%[1]s flag has magic type conversion based on the format of the value:
|
||||
|
||||
- literal values "true", "false", "null", and integer numbers get converted to
|
||||
- literal values %[1]strue%[1]s, %[1]sfalse%[1]s, %[1]snull%[1]s, and integer numbers get converted to
|
||||
appropriate JSON types;
|
||||
- placeholder values "{owner}", "{repo}", and "{branch}" get populated with values
|
||||
- placeholder values %[1]s{owner}%[1]s, %[1]s{repo}%[1]s, and %[1]s{branch}%[1]s get populated with values
|
||||
from the repository of the current directory;
|
||||
- if the value starts with "@", the rest of the value is interpreted as a
|
||||
filename to read the value from. Pass "-" to read from standard input.
|
||||
- if the value starts with %[1]s@%[1]s, the rest of the value is interpreted as a
|
||||
filename to read the value from. Pass %[1]s-%[1]s to read from standard input.
|
||||
|
||||
For GraphQL requests, all fields other than "query" and "operationName" are
|
||||
For GraphQL requests, all fields other than %[1]squery%[1]s and %[1]soperationName%[1]s are
|
||||
interpreted as GraphQL variables.
|
||||
|
||||
To pass nested parameters in the request payload, use "key[subkey]=value" syntax when
|
||||
To pass nested parameters in the request payload, use %[1]skey[subkey]=value%[1]s syntax when
|
||||
declaring fields. To pass nested values as arrays, declare multiple fields with the
|
||||
syntax "key[]=value1", "key[]=value2". To pass an empty array, use "key[]" without a
|
||||
syntax %[1]skey[]=value1%[1]s, %[1]skey[]=value2%[1]s. To pass an empty array, use %[1]skey[]%[1]s without a
|
||||
value.
|
||||
|
||||
To pass pre-constructed JSON or payloads in other formats, a request body may be read
|
||||
from file specified by %[1]s--input%[1]s. Use "-" to read from standard input. When passing the
|
||||
from file specified by %[1]s--input%[1]s. Use %[1]s-%[1]s to read from standard input. When passing the
|
||||
request body this way, any parameters specified via field flags are added to the query
|
||||
string of the endpoint URL.
|
||||
|
||||
In %[1]s--paginate%[1]s mode, all pages of results will sequentially be requested until
|
||||
there are no more pages of results. For GraphQL requests, this requires that the
|
||||
original query accepts an %[1]s$endCursor: String%[1]s variable and that it fetches the
|
||||
%[1]spageInfo{ hasNextPage, endCursor }%[1]s set of fields from a collection.
|
||||
%[1]spageInfo{ hasNextPage, endCursor }%[1]s set of fields from a collection. Each page is a separate
|
||||
JSON array or object. Pass %[1]s--slurp%[1]s to wrap all pages of JSON arrays or objects
|
||||
into an outer JSON array.
|
||||
`, "`"),
|
||||
Example: heredoc.Doc(`
|
||||
# list releases in the current repository
|
||||
|
|
@ -142,6 +149,13 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
|
|||
$ gh api repos/{owner}/{repo}/issues --template \
|
||||
'{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " | color "yellow"}}){{"\n"}}{{end}}'
|
||||
|
||||
# update allowed values of the "environment" custom property in a deeply nested array
|
||||
gh api --PATCH /orgs/{org}/properties/schema \
|
||||
-F 'properties[][property_name]=environment' \
|
||||
-F 'properties[][default_value]=production' \
|
||||
-F 'properties[][allowed_values][]=staging' \
|
||||
-F 'properties[][allowed_values][]=production'
|
||||
|
||||
# list releases with GraphQL
|
||||
$ gh api graphql -F owner='{owner}' -F name='{repo}' -f query='
|
||||
query($name: String!, $owner: String!) {
|
||||
|
|
@ -167,6 +181,22 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
|
|||
}
|
||||
}
|
||||
'
|
||||
|
||||
# get the percentage of forks for the current user
|
||||
$ gh api graphql --paginate --slurp -f query='
|
||||
query($endCursor: String) {
|
||||
viewer {
|
||||
repositories(first: 100, after: $endCursor) {
|
||||
nodes { isFork }
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
' | jq 'def count(e): reduce e as $_ (0;.+1);
|
||||
[.[].data.viewer.repositories.nodes[]] as $r | count(select($r[].isFork))/count($r[])'
|
||||
`),
|
||||
Annotations: map[string]string{
|
||||
"help:environment": heredoc.Doc(`
|
||||
|
|
@ -209,6 +239,19 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
|
|||
return err
|
||||
}
|
||||
|
||||
if opts.Slurp && !opts.Paginate {
|
||||
return cmdutil.FlagErrorf("`--paginate` required when passing `--slurp`")
|
||||
}
|
||||
|
||||
if err := cmdutil.MutuallyExclusive(
|
||||
"the `--slurp` option is not supported with `--jq` or `--template`",
|
||||
opts.Slurp,
|
||||
opts.FilterOutput != "",
|
||||
opts.Template != "",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cmdutil.MutuallyExclusive(
|
||||
"only one of `--template`, `--jq`, `--silent`, or `--verbose` may be used",
|
||||
opts.Verbose,
|
||||
|
|
@ -233,6 +276,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
|
|||
cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add a HTTP request header in `key:value` format")
|
||||
cmd.Flags().StringSliceVarP(&opts.Previews, "preview", "p", nil, "GitHub API preview `names` to request (without the \"-preview\" suffix)")
|
||||
cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response status line and headers in the output")
|
||||
cmd.Flags().BoolVar(&opts.Slurp, "slurp", false, "Use with \"--paginate\" to return an array of all pages of either JSON arrays or objects")
|
||||
cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results")
|
||||
cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request (use \"-\" to read from standard input)")
|
||||
cmd.Flags().BoolVar(&opts.Silent, "silent", false, "Do not print the response body")
|
||||
|
|
@ -265,10 +309,40 @@ func apiRun(opts *ApiOptions) error {
|
|||
method = "POST"
|
||||
}
|
||||
|
||||
if !opts.Silent {
|
||||
if err := opts.IO.StartPager(); err == nil {
|
||||
defer opts.IO.StopPager()
|
||||
} else {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
var bodyWriter io.Writer = opts.IO.Out
|
||||
var headersWriter io.Writer = opts.IO.Out
|
||||
if opts.Silent {
|
||||
bodyWriter = io.Discard
|
||||
}
|
||||
if opts.Verbose {
|
||||
// httpClient handles output when verbose flag is specified.
|
||||
bodyWriter = io.Discard
|
||||
headersWriter = io.Discard
|
||||
}
|
||||
|
||||
if opts.Paginate && !isGraphQL {
|
||||
requestPath = addPerPage(requestPath, 100, params)
|
||||
}
|
||||
|
||||
// Similar to `jq --slurp`, write all pages JSON arrays or objects into a JSON array.
|
||||
if opts.Paginate && opts.Slurp {
|
||||
w := &jsonArrayWriter{
|
||||
Writer: bodyWriter,
|
||||
color: opts.IO.ColorEnabled(),
|
||||
}
|
||||
defer w.Close()
|
||||
|
||||
bodyWriter = w
|
||||
}
|
||||
|
||||
if opts.RequestInputFile != "" {
|
||||
file, size, err := openUserFile(opts.RequestInputFile, opts.IO.In)
|
||||
if err != nil {
|
||||
|
|
@ -298,14 +372,13 @@ func apiRun(opts *ApiOptions) error {
|
|||
log = opts.IO.Out
|
||||
}
|
||||
opts := api.HTTPClientOptions{
|
||||
AppVersion: opts.AppVersion,
|
||||
CacheTTL: opts.CacheTTL,
|
||||
Config: cfg.Authentication(),
|
||||
EnableCache: opts.CacheTTL > 0,
|
||||
Log: log,
|
||||
LogColorize: opts.IO.ColorEnabled(),
|
||||
LogVerboseHTTP: opts.Verbose,
|
||||
SkipAcceptHeaders: true,
|
||||
AppVersion: opts.AppVersion,
|
||||
CacheTTL: opts.CacheTTL,
|
||||
Config: cfg.Authentication(),
|
||||
EnableCache: opts.CacheTTL > 0,
|
||||
Log: log,
|
||||
LogColorize: opts.IO.ColorEnabled(),
|
||||
LogVerboseHTTP: opts.Verbose,
|
||||
}
|
||||
return api.NewHTTPClient(opts)
|
||||
}
|
||||
|
|
@ -315,25 +388,6 @@ func apiRun(opts *ApiOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if !opts.Silent {
|
||||
if err := opts.IO.StartPager(); err == nil {
|
||||
defer opts.IO.StopPager()
|
||||
} else {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
var bodyWriter io.Writer = opts.IO.Out
|
||||
var headersWriter io.Writer = opts.IO.Out
|
||||
if opts.Silent {
|
||||
bodyWriter = io.Discard
|
||||
}
|
||||
if opts.Verbose {
|
||||
// httpClient handles output when verbose flag is specified.
|
||||
bodyWriter = io.Discard
|
||||
headersWriter = io.Discard
|
||||
}
|
||||
|
||||
host, _ := cfg.Authentication().DefaultHost()
|
||||
|
||||
if opts.Hostname != "" {
|
||||
|
|
@ -359,6 +413,12 @@ func apiRun(opts *ApiOptions) error {
|
|||
requestBody = nil // prevent repeating GET parameters
|
||||
}
|
||||
|
||||
// Tell optional jsonArrayWriter to start a new page.
|
||||
err = startPage(bodyWriter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
endCursor, err := processResponse(resp, opts, bodyWriter, headersWriter, tmpl, isFirstPage, !hasNextPage)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -418,7 +478,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW
|
|||
// TODO: reuse parsed query across pagination invocations
|
||||
indent := ""
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
indent = " "
|
||||
indent = ttyIndent
|
||||
}
|
||||
err = jq.EvaluateFormatted(responseBody, bodyWriter, opts.FilterOutput, indent, opts.IO.ColorEnabled())
|
||||
if err != nil {
|
||||
|
|
@ -430,9 +490,9 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW
|
|||
return
|
||||
}
|
||||
} else if isJSON && opts.IO.ColorEnabled() {
|
||||
err = jsoncolor.Write(bodyWriter, responseBody, " ")
|
||||
err = jsoncolor.Write(bodyWriter, responseBody, ttyIndent)
|
||||
} else {
|
||||
if isJSON && opts.Paginate && !isGraphQLPaginate && !opts.ShowResponseHeaders {
|
||||
if isJSON && opts.Paginate && !opts.Slurp && !isGraphQLPaginate && !opts.ShowResponseHeaders {
|
||||
responseBody = &paginatedArrayReader{
|
||||
Reader: responseBody,
|
||||
isFirstPage: isFirstPage,
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
|
@ -16,6 +16,8 @@ import (
|
|||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/git"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
ghmock "github.com/cli/cli/v2/internal/gh/mock"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
|
|
@ -328,6 +330,21 @@ func Test_NewCmdApi(t *testing.T) {
|
|||
cli: "user --jq .foo -t '{{.foo}}'",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "--slurp without --paginate",
|
||||
cli: "user --slurp",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "slurp with --jq",
|
||||
cli: "user --paginate --slurp --jq .foo",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "slurp with --template",
|
||||
cli: "user --paginate --slurp --template '{{.foo}}'",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "with verbose",
|
||||
cli: "user --verbose",
|
||||
|
|
@ -551,6 +568,34 @@ func Test_apiRun(t *testing.T) {
|
|||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "output template with range",
|
||||
options: ApiOptions{
|
||||
Template: `{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " }}){{"\n"}}{{end}}`,
|
||||
},
|
||||
httpResponse: &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[
|
||||
{
|
||||
"title": "First title",
|
||||
"labels": [{"name":"bug"}, {"name":"help wanted"}]
|
||||
},
|
||||
{
|
||||
"title": "Second but not last"
|
||||
},
|
||||
{
|
||||
"title": "Alas, tis' the end",
|
||||
"labels": [{}, {"name":"feature"}]
|
||||
}
|
||||
]`)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
},
|
||||
stdout: heredoc.Doc(`
|
||||
First title (bug, help wanted)
|
||||
Second but not last ()
|
||||
Alas, tis' the end (, feature)
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "output template when REST error",
|
||||
options: ApiOptions{
|
||||
|
|
@ -619,7 +664,7 @@ func Test_apiRun(t *testing.T) {
|
|||
ios.SetStdoutTTY(tt.isatty)
|
||||
|
||||
tt.options.IO = ios
|
||||
tt.options.Config = func() (config.Config, error) { return config.NewBlankConfig(), nil }
|
||||
tt.options.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil }
|
||||
tt.options.HttpClient = func() (*http.Client, error) {
|
||||
var tr roundTripper = func(req *http.Request) (*http.Response, error) {
|
||||
resp := tt.httpResponse
|
||||
|
|
@ -650,23 +695,36 @@ func Test_apiRun_paginationREST(t *testing.T) {
|
|||
requestCount := 0
|
||||
responses := []*http.Response{
|
||||
{
|
||||
Proto: "HTTP/1.1",
|
||||
Status: "200 OK",
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"page":1}`)),
|
||||
Header: http.Header{
|
||||
"Link": []string{`<https://api.github.com/repositories/1227/issues?page=2>; rel="next", <https://api.github.com/repositories/1227/issues?page=3>; rel="last"`},
|
||||
"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=3>; rel="last"`},
|
||||
"X-Github-Request-Id": []string{"1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Proto: "HTTP/1.1",
|
||||
Status: "200 OK",
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"page":2}`)),
|
||||
Header: http.Header{
|
||||
"Link": []string{`<https://api.github.com/repositories/1227/issues?page=3>; rel="next", <https://api.github.com/repositories/1227/issues?page=3>; rel="last"`},
|
||||
"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=3>; rel="last"`},
|
||||
"X-Github-Request-Id": []string{"2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Proto: "HTTP/1.1",
|
||||
Status: "200 OK",
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{"page":3}`)),
|
||||
Header: http.Header{},
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Github-Request-Id": []string{"3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -681,7 +739,7 @@ func Test_apiRun_paginationREST(t *testing.T) {
|
|||
}
|
||||
return &http.Client{Transport: tr}, nil
|
||||
},
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
|
||||
|
|
@ -753,7 +811,7 @@ func Test_apiRun_arrayPaginationREST(t *testing.T) {
|
|||
}
|
||||
return &http.Client{Transport: tr}, nil
|
||||
},
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
|
||||
|
|
@ -775,36 +833,42 @@ func Test_apiRun_arrayPaginationREST(t *testing.T) {
|
|||
assert.Equal(t, "https://api.github.com/repositories/1227/issues?page=3", responses[2].Request.URL.String())
|
||||
}
|
||||
|
||||
func Test_apiRun_paginationGraphQL(t *testing.T) {
|
||||
func Test_apiRun_arrayPaginationREST_with_headers(t *testing.T) {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
|
||||
requestCount := 0
|
||||
responses := []*http.Response{
|
||||
{
|
||||
Proto: "HTTP/1.1",
|
||||
Status: "200 OK",
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": []string{`application/json`}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{
|
||||
"data": {
|
||||
"nodes": ["page one"],
|
||||
"pageInfo": {
|
||||
"endCursor": "PAGE1_END",
|
||||
"hasNextPage": true
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"page":1}]`)),
|
||||
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=3>; rel="last"`},
|
||||
"X-Github-Request-Id": []string{"1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Proto: "HTTP/1.1",
|
||||
Status: "200 OK",
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": []string{`application/json`}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(`{
|
||||
"data": {
|
||||
"nodes": ["page two"],
|
||||
"pageInfo": {
|
||||
"endCursor": "PAGE2_END",
|
||||
"hasNextPage": false
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"page":2}]`)),
|
||||
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=3>; rel="last"`},
|
||||
"X-Github-Request-Id": []string{"2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Proto: "HTTP/1.1",
|
||||
Status: "200 OK",
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"page":3}]`)),
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Github-Request-Id": []string{"3"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -819,7 +883,76 @@ func Test_apiRun_paginationGraphQL(t *testing.T) {
|
|||
}
|
||||
return &http.Client{Transport: tr}, nil
|
||||
},
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
|
||||
RequestMethod: "GET",
|
||||
RequestMethodPassed: true,
|
||||
RequestPath: "issues",
|
||||
Paginate: true,
|
||||
RawFields: []string{"per_page=50", "page=1"},
|
||||
ShowResponseHeaders: true,
|
||||
}
|
||||
|
||||
err := apiRun(&options)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "HTTP/1.1 200 OK\nContent-Type: application/json\r\nLink: <https://api.github.com/repositories/1227/issues?page=2>; rel=\"next\", <https://api.github.com/repositories/1227/issues?page=3>; rel=\"last\"\r\nX-Github-Request-Id: 1\r\n\r\n[{\"page\":1}]\nHTTP/1.1 200 OK\nContent-Type: application/json\r\nLink: <https://api.github.com/repositories/1227/issues?page=3>; rel=\"next\", <https://api.github.com/repositories/1227/issues?page=3>; rel=\"last\"\r\nX-Github-Request-Id: 2\r\n\r\n[{\"page\":2}]\nHTTP/1.1 200 OK\nContent-Type: application/json\r\nX-Github-Request-Id: 3\r\n\r\n[{\"page\":3}]", 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()
|
||||
|
||||
requestCount := 0
|
||||
responses := []*http.Response{
|
||||
{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": []string{`application/json`}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(heredoc.Doc(`
|
||||
{
|
||||
"data": {
|
||||
"nodes": ["page one"],
|
||||
"pageInfo": {
|
||||
"endCursor": "PAGE1_END",
|
||||
"hasNextPage": true
|
||||
}
|
||||
}
|
||||
}`))),
|
||||
},
|
||||
{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": []string{`application/json`}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(heredoc.Doc(`
|
||||
{
|
||||
"data": {
|
||||
"nodes": ["page two"],
|
||||
"pageInfo": {
|
||||
"endCursor": "PAGE2_END",
|
||||
"hasNextPage": false
|
||||
}
|
||||
}
|
||||
}`))),
|
||||
},
|
||||
}
|
||||
|
||||
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() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
|
||||
|
|
@ -832,8 +965,127 @@ func Test_apiRun_paginationGraphQL(t *testing.T) {
|
|||
err := apiRun(&options)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, stdout.String(), `"page one"`)
|
||||
assert.Contains(t, stdout.String(), `"page two"`)
|
||||
assert.Equal(t, heredoc.Doc(`
|
||||
{
|
||||
"data": {
|
||||
"nodes": ["page one"],
|
||||
"pageInfo": {
|
||||
"endCursor": "PAGE1_END",
|
||||
"hasNextPage": true
|
||||
}
|
||||
}
|
||||
}{
|
||||
"data": {
|
||||
"nodes": ["page two"],
|
||||
"pageInfo": {
|
||||
"endCursor": "PAGE2_END",
|
||||
"hasNextPage": false
|
||||
}
|
||||
}
|
||||
}`), stdout.String())
|
||||
assert.Equal(t, "", stderr.String(), "stderr")
|
||||
|
||||
var requestData struct {
|
||||
Variables map[string]interface{}
|
||||
}
|
||||
|
||||
bb, err := io.ReadAll(responses[0].Request.Body)
|
||||
require.NoError(t, err)
|
||||
err = json.Unmarshal(bb, &requestData)
|
||||
require.NoError(t, err)
|
||||
_, hasCursor := requestData.Variables["endCursor"].(string)
|
||||
assert.Equal(t, false, hasCursor)
|
||||
|
||||
bb, err = io.ReadAll(responses[1].Request.Body)
|
||||
require.NoError(t, err)
|
||||
err = json.Unmarshal(bb, &requestData)
|
||||
require.NoError(t, err)
|
||||
endCursor, hasCursor := requestData.Variables["endCursor"].(string)
|
||||
assert.Equal(t, true, hasCursor)
|
||||
assert.Equal(t, "PAGE1_END", endCursor)
|
||||
}
|
||||
|
||||
func Test_apiRun_paginationGraphQL_slurp(t *testing.T) {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
|
||||
requestCount := 0
|
||||
responses := []*http.Response{
|
||||
{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": []string{`application/json`}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(heredoc.Doc(`
|
||||
{
|
||||
"data": {
|
||||
"nodes": ["page one"],
|
||||
"pageInfo": {
|
||||
"endCursor": "PAGE1_END",
|
||||
"hasNextPage": true
|
||||
}
|
||||
}
|
||||
}`))),
|
||||
},
|
||||
{
|
||||
StatusCode: 200,
|
||||
Header: http.Header{"Content-Type": []string{`application/json`}},
|
||||
Body: io.NopCloser(bytes.NewBufferString(heredoc.Doc(`
|
||||
{
|
||||
"data": {
|
||||
"nodes": ["page two"],
|
||||
"pageInfo": {
|
||||
"endCursor": "PAGE2_END",
|
||||
"hasNextPage": false
|
||||
}
|
||||
}
|
||||
}`))),
|
||||
},
|
||||
}
|
||||
|
||||
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() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
|
||||
RawFields: []string{"foo=bar"},
|
||||
RequestMethod: "POST",
|
||||
RequestPath: "graphql",
|
||||
Paginate: true,
|
||||
Slurp: true,
|
||||
}
|
||||
|
||||
err := apiRun(&options)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.JSONEq(t, stdout.String(), `[
|
||||
{
|
||||
"data": {
|
||||
"nodes": ["page one"],
|
||||
"pageInfo": {
|
||||
"endCursor": "PAGE1_END",
|
||||
"hasNextPage": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
"data": {
|
||||
"nodes": ["page two"],
|
||||
"pageInfo": {
|
||||
"endCursor": "PAGE2_END",
|
||||
"hasNextPage": false
|
||||
}
|
||||
}
|
||||
}
|
||||
]`)
|
||||
assert.Equal(t, "", stderr.String(), "stderr")
|
||||
|
||||
var requestData struct {
|
||||
|
|
@ -911,7 +1163,7 @@ func Test_apiRun_paginated_template(t *testing.T) {
|
|||
}
|
||||
return &http.Client{Transport: tr}, nil
|
||||
},
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
|
||||
|
|
@ -958,7 +1210,7 @@ func Test_apiRun_DELETE(t *testing.T) {
|
|||
var gotRequest *http.Request
|
||||
err := apiRun(&ApiOptions{
|
||||
IO: ios,
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
HttpClient: func() (*http.Client, error) {
|
||||
|
|
@ -1043,7 +1295,7 @@ func Test_apiRun_inputFile(t *testing.T) {
|
|||
}
|
||||
return &http.Client{Transport: tr}, nil
|
||||
},
|
||||
Config: func() (config.Config, error) {
|
||||
Config: func() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
}
|
||||
|
|
@ -1063,40 +1315,41 @@ func Test_apiRun_inputFile(t *testing.T) {
|
|||
}
|
||||
|
||||
func Test_apiRun_cache(t *testing.T) {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
|
||||
// Given we have a test server that spies on the number of requests it receives
|
||||
requestCount := 0
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
t.Cleanup(s.Close)
|
||||
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
options := ApiOptions{
|
||||
IO: ios,
|
||||
HttpClient: func() (*http.Client, error) {
|
||||
var tr roundTripper = func(req *http.Request) (*http.Response, error) {
|
||||
requestCount++
|
||||
return &http.Response{
|
||||
Request: req,
|
||||
StatusCode: 204,
|
||||
}, nil
|
||||
}
|
||||
return &http.Client{Transport: tr}, nil
|
||||
Config: func() (gh.Config, error) {
|
||||
return &ghmock.ConfigMock{
|
||||
AuthenticationFunc: func() gh.AuthConfig {
|
||||
return &config.AuthConfig{}
|
||||
},
|
||||
// Cached responses are stored in a tempdir that gets automatically cleaned up
|
||||
CacheDirFunc: func() string {
|
||||
return t.TempDir()
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
Config: func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
|
||||
RequestPath: "issues",
|
||||
// You might think that we want to set Host: s.URL here, but you'd be wrong.
|
||||
// The host field is later used to evaluate an API URL e.g. https://api.host.com/graphql
|
||||
// The RequestPath field is used exactly as is, for the request if it includes a host.
|
||||
RequestPath: s.URL,
|
||||
CacheTTL: time.Minute,
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
cacheDir := filepath.Join(os.TempDir(), "gh-cli-cache")
|
||||
os.RemoveAll(cacheDir)
|
||||
})
|
||||
// When we run the API behaviour twice
|
||||
require.NoError(t, apiRun(&options))
|
||||
require.NoError(t, apiRun(&options))
|
||||
|
||||
err := apiRun(&options)
|
||||
assert.NoError(t, err)
|
||||
err = apiRun(&options)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, 2, requestCount)
|
||||
// We only get one request to the http server because it uses the cached response
|
||||
assert.Equal(t, 1, requestCount)
|
||||
assert.Equal(t, "", stdout.String(), "stdout")
|
||||
assert.Equal(t, "", stderr.String(), "stderr")
|
||||
}
|
||||
|
|
@ -1455,3 +1708,58 @@ func Test_parseErrorResponse(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_apiRun_acceptHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
options ApiOptions
|
||||
wantAcceptHeader string
|
||||
}{
|
||||
{
|
||||
name: "sets default accept header",
|
||||
options: ApiOptions{},
|
||||
wantAcceptHeader: "*/*",
|
||||
},
|
||||
{
|
||||
name: "does not override user accept header",
|
||||
options: ApiOptions{
|
||||
RequestHeaders: []string{"Accept: testing"},
|
||||
},
|
||||
wantAcceptHeader: "testing",
|
||||
},
|
||||
{
|
||||
name: "does not override preview names",
|
||||
options: ApiOptions{
|
||||
Previews: []string{"nebula"},
|
||||
},
|
||||
wantAcceptHeader: "application/vnd.github.nebula-preview+json",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
tt.options.IO = ios
|
||||
|
||||
tt.options.Config = func() (gh.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
}
|
||||
|
||||
var gotReq *http.Request
|
||||
tt.options.HttpClient = func() (*http.Client, error) {
|
||||
var tr roundTripper = func(req *http.Request) (*http.Response, error) {
|
||||
gotReq = req
|
||||
resp := &http.Response{
|
||||
StatusCode: 200,
|
||||
Request: req,
|
||||
Body: io.NopCloser(bytes.NewBufferString("")),
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
return &http.Client{Transport: tr}, nil
|
||||
}
|
||||
|
||||
assert.NoError(t, apiRun(&tt.options))
|
||||
assert.Equal(t, tt.wantAcceptHeader, gotReq.Header.Get("Accept"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package api
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
|
@ -98,6 +99,9 @@ func parseFields(opts *ApiOptions) (map[string]interface{}, error) {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
if _, exists := destMap[subkey]; exists {
|
||||
return fmt.Errorf("unexpected override existing field under %q", subkey)
|
||||
}
|
||||
destMap[subkey] = value
|
||||
}
|
||||
return nil
|
||||
|
|
@ -136,6 +140,8 @@ func addParamsSlice(m map[string]interface{}, prevkey, newkey string) (map[strin
|
|||
if lastMap, ok := lastItem.(map[string]interface{}); ok {
|
||||
if _, keyExists := lastMap[newkey]; !keyExists {
|
||||
return lastMap, nil
|
||||
} else if reflect.TypeOf(lastMap[newkey]).Kind() == reflect.Slice {
|
||||
return lastMap, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_parseFields(t *testing.T) {
|
||||
|
|
@ -61,8 +62,14 @@ func Test_parseFields_nested(t *testing.T) {
|
|||
"robots[]=Dependabot",
|
||||
"labels[][name]=bug",
|
||||
"labels[][color]=red",
|
||||
"labels[][colorOptions][]=red",
|
||||
"labels[][colorOptions][]=blue",
|
||||
"labels[][name]=feature",
|
||||
"labels[][color]=green",
|
||||
"labels[][colorOptions][]=red",
|
||||
"labels[][colorOptions][]=green",
|
||||
"labels[][colorOptions][]=yellow",
|
||||
"nested[][key1][key2][key3]=value",
|
||||
"empty[]",
|
||||
},
|
||||
MagicFields: []string{
|
||||
|
|
@ -96,13 +103,31 @@ func Test_parseFields_nested(t *testing.T) {
|
|||
"labels": [
|
||||
{
|
||||
"color": "red",
|
||||
"colorOptions": [
|
||||
"red",
|
||||
"blue"
|
||||
],
|
||||
"name": "bug"
|
||||
},
|
||||
{
|
||||
"color": "green",
|
||||
"colorOptions": [
|
||||
"red",
|
||||
"green",
|
||||
"yellow"
|
||||
],
|
||||
"name": "feature"
|
||||
}
|
||||
],
|
||||
"nested": [
|
||||
{
|
||||
"key1": {
|
||||
"key2": {
|
||||
"key3": "value"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"robots": [
|
||||
"Hubot",
|
||||
"Dependabot"
|
||||
|
|
@ -111,6 +136,91 @@ func Test_parseFields_nested(t *testing.T) {
|
|||
`), "\n"), string(jsonData))
|
||||
}
|
||||
|
||||
func Test_parseFields_errors(t *testing.T) {
|
||||
ios, stdin, _, _ := iostreams.Test()
|
||||
fmt.Fprint(stdin, "pasted contents")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *ApiOptions
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "cannot overwrite string to array",
|
||||
opts: &ApiOptions{
|
||||
IO: ios,
|
||||
RawFields: []string{
|
||||
"object[field]=A",
|
||||
"object[field][]=this should be an error",
|
||||
},
|
||||
},
|
||||
expected: `expected array type under "field", got string`,
|
||||
},
|
||||
{
|
||||
name: "cannot overwrite string to object",
|
||||
opts: &ApiOptions{
|
||||
IO: ios,
|
||||
RawFields: []string{
|
||||
"object[field]=B",
|
||||
"object[field][field2]=this should be an error",
|
||||
},
|
||||
},
|
||||
expected: `expected map type under "field", got string`,
|
||||
},
|
||||
{
|
||||
name: "cannot overwrite object to string",
|
||||
opts: &ApiOptions{
|
||||
IO: ios,
|
||||
RawFields: []string{
|
||||
"object[field][field2]=C",
|
||||
"object[field]=this should be an error",
|
||||
},
|
||||
},
|
||||
expected: `unexpected override existing field under "field"`,
|
||||
},
|
||||
{
|
||||
name: "cannot overwrite object to array",
|
||||
opts: &ApiOptions{
|
||||
IO: ios,
|
||||
RawFields: []string{
|
||||
"object[field][field2]=D",
|
||||
"object[field][]=this should be an error",
|
||||
},
|
||||
},
|
||||
expected: `expected array type under "field", got map[string]interface {}`,
|
||||
},
|
||||
{
|
||||
name: "cannot overwrite array to string",
|
||||
opts: &ApiOptions{
|
||||
IO: ios,
|
||||
RawFields: []string{
|
||||
"object[field][]=E",
|
||||
"object[field]=this should be an error",
|
||||
},
|
||||
},
|
||||
expected: `unexpected override existing field under "field"`,
|
||||
},
|
||||
{
|
||||
name: "cannot overwrite array to object",
|
||||
opts: &ApiOptions{
|
||||
IO: ios,
|
||||
RawFields: []string{
|
||||
"object[field][]=F",
|
||||
"object[field][field2]=this should be an error",
|
||||
},
|
||||
},
|
||||
expected: `expected map type under "field", got []interface {}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := parseFields(tt.opts)
|
||||
require.EqualError(t, err, tt.expected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_magicFieldValue(t *testing.T) {
|
||||
f, err := os.CreateTemp(t.TempDir(), "gh-test")
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ func httpRequest(client *http.Client, hostname string, method string, p string,
|
|||
if bodyIsJSON && req.Header.Get("Content-Type") == "" {
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
if req.Header.Get("Accept") == "" {
|
||||
req.Header.Set("Accept", "*/*")
|
||||
}
|
||||
|
||||
return client.Do(req)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,7 +126,25 @@ func Test_httpRequest(t *testing.T) {
|
|||
method: "GET",
|
||||
u: "https://api.github.com/repos/octocat/spoon-knife",
|
||||
body: "",
|
||||
headers: "",
|
||||
headers: "Accept: */*\r\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GET with accept header",
|
||||
args: args{
|
||||
client: &httpClient,
|
||||
host: "github.com",
|
||||
method: "GET",
|
||||
p: "repos/octocat/spoon-knife",
|
||||
params: nil,
|
||||
headers: []string{"Accept: testing"},
|
||||
},
|
||||
wantErr: false,
|
||||
want: expects{
|
||||
method: "GET",
|
||||
u: "https://api.github.com/repos/octocat/spoon-knife",
|
||||
body: "",
|
||||
headers: "Accept: testing\r\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -144,7 +162,7 @@ func Test_httpRequest(t *testing.T) {
|
|||
method: "GET",
|
||||
u: "https://api.github.com/repos/octocat/spoon-knife",
|
||||
body: "",
|
||||
headers: "",
|
||||
headers: "Accept: */*\r\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -162,7 +180,7 @@ func Test_httpRequest(t *testing.T) {
|
|||
method: "GET",
|
||||
u: "https://api.github.com/repos/octocat/spoon-knife",
|
||||
body: "",
|
||||
headers: "",
|
||||
headers: "Accept: */*\r\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -180,7 +198,7 @@ func Test_httpRequest(t *testing.T) {
|
|||
method: "GET",
|
||||
u: "https://example.org/api/v3/repos/octocat/spoon-knife",
|
||||
body: "",
|
||||
headers: "",
|
||||
headers: "Accept: */*\r\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -200,7 +218,7 @@ func Test_httpRequest(t *testing.T) {
|
|||
method: "GET",
|
||||
u: "https://api.github.com/repos/octocat/spoon-knife?a=b",
|
||||
body: "",
|
||||
headers: "",
|
||||
headers: "Accept: */*\r\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -220,7 +238,7 @@ func Test_httpRequest(t *testing.T) {
|
|||
method: "POST",
|
||||
u: "https://api.github.com/repos",
|
||||
body: `{"a":"b"}`,
|
||||
headers: "Content-Type: application/json; charset=utf-8\r\n",
|
||||
headers: "Accept: */*\r\nContent-Type: application/json; charset=utf-8\r\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -240,7 +258,7 @@ func Test_httpRequest(t *testing.T) {
|
|||
method: "POST",
|
||||
u: "https://api.github.com/graphql",
|
||||
body: `{"variables":{"a":"b"}}`,
|
||||
headers: "Content-Type: application/json; charset=utf-8\r\n",
|
||||
headers: "Accept: */*\r\nContent-Type: application/json; charset=utf-8\r\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -258,7 +276,7 @@ func Test_httpRequest(t *testing.T) {
|
|||
method: "POST",
|
||||
u: "https://example.org/api/graphql",
|
||||
body: `{}`,
|
||||
headers: "Content-Type: application/json; charset=utf-8\r\n",
|
||||
headers: "Accept: */*\r\nContent-Type: application/json; charset=utf-8\r\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue