Merge branch 'trunk' into feature-macos-pkg-installer

This commit is contained in:
Paul 2024-05-20 17:05:23 +02:00 committed by GitHub
commit 630ab13461
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
498 changed files with 27825 additions and 7758 deletions

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,7 +4,7 @@
![screenshot of gh pr status](https://user-images.githubusercontent.com/98482/84171218-327e7a80-aa40-11ea-8cd1-5177fc2d0e72.png)
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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{},

View file

@ -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")
}

View file

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

View file

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

View file

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

View file

@ -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
View 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).

View file

@ -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
View 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`.

View file

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

View file

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

View file

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

View file

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

View file

@ -67,6 +67,7 @@ func (r TrackingRef) String() string {
type Commit struct {
Sha string
Title string
Body string
}
type BranchConfig struct {

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
}

View 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)
}
}

View 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)
}

View 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()
}

View 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)
}
})
}
}

View file

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

View file

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

View file

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

View 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")
}

View file

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

View file

@ -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")
}

View file

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

View file

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

View 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)
}

View file

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

View file

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

View 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})
}

View 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
}

View 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
}

View 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)
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 }} &lt;{{.Varname}}&gt;{{ end }}</code></dt>
<dt>{{ if .Shorthand }}<code>-{{.Shorthand}}</code>, {{ end }}
<code>--{{.Name}}{{ if .Varname }} &lt;{{.Varname}}&gt;{{ 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 {

View file

@ -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 &#34;T1&#34;)")
// 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])")
}

View file

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

View 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
}

View file

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

View file

@ -35,7 +35,7 @@ var _ Prompter = &PrompterMock{}
// MarkdownEditorFunc: func(s1 string, s2 string, b bool) (string, error) {
// panic("mock out the MarkdownEditor method")
// },
// MultiSelectFunc: func(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

View file

@ -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, ", "))
}

View file

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

View 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)
}

View file

@ -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()
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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())
})
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"))
})
}
}

View file

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

View file

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

View file

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

View file

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