Merge pull request #11544 from benjlevesque/auth-status-json-output-8635

Add JSON output to `gh auth status`
This commit is contained in:
Kynan Ware 2025-09-25 09:50:46 -06:00 committed by GitHub
commit b76bc7706a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 455 additions and 161 deletions

View file

@ -19,113 +19,106 @@ import (
"github.com/spf13/cobra"
)
type validEntry struct {
active bool
host string
user string
token string
tokenSource string
gitProtocol string
scopes string
type authEntryState string
const (
authEntryStateSuccess = "success"
authEntryStateTimeout = "timeout"
authEntryStateError = "error"
)
type authEntry struct {
State authEntryState `json:"state"`
Error string `json:"error,omitempty"`
Active bool `json:"active"`
Host string `json:"host"`
Login string `json:"login"`
TokenSource string `json:"tokenSource"`
Token string `json:"token,omitempty"`
Scopes string `json:"scopes,omitempty"`
GitProtocol string `json:"gitProtocol"`
}
func (e validEntry) String(cs *iostreams.ColorScheme) string {
type authStatus struct {
Hosts map[string][]authEntry `json:"hosts"`
}
func newAuthStatus() *authStatus {
return &authStatus{
Hosts: make(map[string][]authEntry),
}
}
var authStatusFields = []string{
"hosts",
}
func (a authStatus) ExportData(fields []string) map[string]interface{} {
return cmdutil.StructExportData(a, fields)
}
func (e authEntry) String(cs *iostreams.ColorScheme) string {
var sb strings.Builder
sb.WriteString(
fmt.Sprintf(" %s Logged in to %s account %s (%s)\n", cs.SuccessIcon(), e.host, cs.Bold(e.user), e.tokenSource),
)
activeStr := fmt.Sprintf("%v", e.active)
sb.WriteString(fmt.Sprintf(" - Active account: %s\n", cs.Bold(activeStr)))
sb.WriteString(fmt.Sprintf(" - Git operations protocol: %s\n", cs.Bold(e.gitProtocol)))
sb.WriteString(fmt.Sprintf(" - Token: %s\n", cs.Bold(e.token)))
switch e.State {
case authEntryStateSuccess:
sb.WriteString(
fmt.Sprintf(" %s Logged in to %s account %s (%s)\n", cs.SuccessIcon(), e.Host, cs.Bold(e.Login), e.TokenSource),
)
activeStr := fmt.Sprintf("%v", e.Active)
sb.WriteString(fmt.Sprintf(" - Active account: %s\n", cs.Bold(activeStr)))
sb.WriteString(fmt.Sprintf(" - Git operations protocol: %s\n", cs.Bold(e.GitProtocol)))
sb.WriteString(fmt.Sprintf(" - Token: %s\n", cs.Bold(e.Token)))
if expectScopes(e.token) {
sb.WriteString(fmt.Sprintf(" - Token scopes: %s\n", cs.Bold(displayScopes(e.scopes))))
if err := shared.HeaderHasMinimumScopes(e.scopes); err != nil {
var missingScopesError *shared.MissingScopesError
if errors.As(err, &missingScopesError) {
missingScopes := strings.Join(missingScopesError.MissingScopes, ",")
sb.WriteString(fmt.Sprintf(" %s Missing required token scopes: %s\n",
cs.WarningIcon(),
cs.Bold(displayScopes(missingScopes))))
refreshInstructions := fmt.Sprintf("gh auth refresh -h %s", e.host)
sb.WriteString(fmt.Sprintf(" - To request missing scopes, run: %s\n", cs.Bold(refreshInstructions)))
if expectScopes(e.Token) {
sb.WriteString(fmt.Sprintf(" - Token scopes: %s\n", cs.Bold(displayScopes(e.Scopes))))
if err := shared.HeaderHasMinimumScopes(e.Scopes); err != nil {
var missingScopesError *shared.MissingScopesError
if errors.As(err, &missingScopesError) {
missingScopes := strings.Join(missingScopesError.MissingScopes, ",")
sb.WriteString(fmt.Sprintf(" %s Missing required token scopes: %s\n",
cs.WarningIcon(),
cs.Bold(displayScopes(missingScopes))))
refreshInstructions := fmt.Sprintf("gh auth refresh -h %s", e.Host)
sb.WriteString(fmt.Sprintf(" - To request missing scopes, run: %s\n", cs.Bold(refreshInstructions)))
}
}
}
case authEntryStateError:
if e.Login != "" {
sb.WriteString(fmt.Sprintf(" %s Failed to log in to %s account %s (%s)\n", cs.Red("X"), e.Host, cs.Bold(e.Login), e.TokenSource))
} else {
sb.WriteString(fmt.Sprintf(" %s Failed to log in to %s using token (%s)\n", cs.Red("X"), e.Host, e.TokenSource))
}
activeStr := fmt.Sprintf("%v", e.Active)
sb.WriteString(fmt.Sprintf(" - Active account: %s\n", cs.Bold(activeStr)))
sb.WriteString(fmt.Sprintf(" - The token in %s is invalid.\n", e.TokenSource))
if authTokenWriteable(e.TokenSource) {
loginInstructions := fmt.Sprintf("gh auth login -h %s", e.Host)
logoutInstructions := fmt.Sprintf("gh auth logout -h %s -u %s", e.Host, e.Login)
sb.WriteString(fmt.Sprintf(" - To re-authenticate, run: %s\n", cs.Bold(loginInstructions)))
sb.WriteString(fmt.Sprintf(" - To forget about this account, run: %s\n", cs.Bold(logoutInstructions)))
}
case authEntryStateTimeout:
if e.Login != "" {
sb.WriteString(fmt.Sprintf(" %s Timeout trying to log in to %s account %s (%s)\n", cs.Red("X"), e.Host, cs.Bold(e.Login), e.TokenSource))
} else {
sb.WriteString(fmt.Sprintf(" %s Timeout trying to log in to %s using token (%s)\n", cs.Red("X"), e.Host, e.TokenSource))
}
activeStr := fmt.Sprintf("%v", e.Active)
sb.WriteString(fmt.Sprintf(" - Active account: %s\n", cs.Bold(activeStr)))
}
return sb.String()
}
type invalidTokenEntry struct {
active bool
host string
user string
tokenSource string
tokenIsWriteable bool
}
func (e invalidTokenEntry) String(cs *iostreams.ColorScheme) string {
var sb strings.Builder
if e.user != "" {
sb.WriteString(fmt.Sprintf(" %s Failed to log in to %s account %s (%s)\n", cs.Red("X"), e.host, cs.Bold(e.user), e.tokenSource))
} else {
sb.WriteString(fmt.Sprintf(" %s Failed to log in to %s using token (%s)\n", cs.Red("X"), e.host, e.tokenSource))
}
activeStr := fmt.Sprintf("%v", e.active)
sb.WriteString(fmt.Sprintf(" - Active account: %s\n", cs.Bold(activeStr)))
sb.WriteString(fmt.Sprintf(" - The token in %s is invalid.\n", e.tokenSource))
if e.tokenIsWriteable {
loginInstructions := fmt.Sprintf("gh auth login -h %s", e.host)
logoutInstructions := fmt.Sprintf("gh auth logout -h %s -u %s", e.host, e.user)
sb.WriteString(fmt.Sprintf(" - To re-authenticate, run: %s\n", cs.Bold(loginInstructions)))
sb.WriteString(fmt.Sprintf(" - To forget about this account, run: %s\n", cs.Bold(logoutInstructions)))
}
return sb.String()
}
type timeoutErrorEntry struct {
active bool
host string
user string
tokenSource string
}
func (e timeoutErrorEntry) String(cs *iostreams.ColorScheme) string {
var sb strings.Builder
if e.user != "" {
sb.WriteString(fmt.Sprintf(" %s Timeout trying to log in to %s account %s (%s)\n", cs.Red("X"), e.host, cs.Bold(e.user), e.tokenSource))
} else {
sb.WriteString(fmt.Sprintf(" %s Timeout trying to log in to %s using token (%s)\n", cs.Red("X"), e.host, e.tokenSource))
}
activeStr := fmt.Sprintf("%v", e.active)
sb.WriteString(fmt.Sprintf(" - Active account: %s\n", cs.Bold(activeStr)))
return sb.String()
}
type Entry interface {
String(cs *iostreams.ColorScheme) string
}
type Entries []Entry
func (e Entries) Strings(cs *iostreams.ColorScheme) []string {
var out []string
for _, entry := range e {
out = append(out, entry.String(cs))
}
return out
}
type StatusOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
Config func() (gh.Config, error)
Exporter cmdutil.Exporter
Hostname string
ShowToken bool
@ -148,11 +141,32 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co
For each host, the authentication state of each known account is tested and any issues are included in the output.
Each host section will indicate the active account, which will be used when targeting that host.
If an account on any host (or only the one given via %[1]s--hostname%[1]s) has authentication issues,
the command will exit with 1 and output to stderr.
the command will exit with 1 and output to stderr. Note that when using the %[1]s--json%[1]s option, the command
will always exit with zero regardless of any authentication issues, unless there is a fatal error.
To change the active account for a host, see %[1]sgh auth switch%[1]s.
`, "`"),
Example: heredoc.Doc(`
# Display authentication status for all accounts on all hosts
$ gh auth status
# Display authentication status for the active account on a specific host
$ gh auth status --active --hostname github.example.com
# Display tokens in plain text
$ gh auth status --show-token
# Format authentication status as JSON
$ gh auth status --json hosts
# Include plain text token in JSON output
$ gh auth status --json hosts --show-token
# Format hosts as a flat JSON array
$ gh auth status --json hosts --jq '.hosts | add'
`),
RunE: func(cmd *cobra.Command, args []string) error {
if runF != nil {
return runF(opts)
@ -166,6 +180,9 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co
cmd.Flags().BoolVarP(&opts.ShowToken, "show-token", "t", false, "Display the auth token")
cmd.Flags().BoolVarP(&opts.Active, "active", "a", false, "Display the active account only")
// the json flags are intentionally not given a shorthand to avoid conflict with -t/--show-token
cmdutil.AddJSONFlagsWithoutShorthand(cmd, &opts.Exporter, authStatusFields)
return cmd
}
@ -180,18 +197,26 @@ func statusRun(opts *StatusOptions) error {
stdout := opts.IO.Out
cs := opts.IO.ColorScheme()
statuses := make(map[string]Entries)
hostnames := authCfg.Hosts()
if len(hostnames) == 0 {
fmt.Fprintf(stderr,
"You are not logged into any GitHub hosts. To log in, run: %s\n", cs.Bold("gh auth login"))
if opts.Exporter != nil {
// In machine-friendly mode, we always exit with no error.
opts.Exporter.Write(opts.IO, newAuthStatus())
return nil
}
return cmdutil.SilentError
}
if opts.Hostname != "" && !slices.Contains(hostnames, opts.Hostname) {
fmt.Fprintf(stderr,
"You are not logged into any accounts on %s\n", opts.Hostname)
if opts.Exporter != nil {
// In machine-friendly mode, we always exit with no error.
opts.Exporter.Write(opts.IO, newAuthStatus())
return nil
}
return cmdutil.SilentError
}
@ -200,6 +225,9 @@ func statusRun(opts *StatusOptions) error {
return err
}
var finalErr error
statuses := newAuthStatus()
for _, hostname := range hostnames {
if opts.Hostname != "" && opts.Hostname != hostname {
continue
@ -215,15 +243,14 @@ func statusRun(opts *StatusOptions) error {
active: true,
gitProtocol: gitProtocol,
hostname: hostname,
showToken: opts.ShowToken,
token: activeUserToken,
tokenSource: activeUserTokenSource,
username: activeUser,
})
statuses[hostname] = append(statuses[hostname], entry)
statuses.Hosts[hostname] = append(statuses.Hosts[hostname], entry)
if err == nil && !isValidEntry(entry) {
err = cmdutil.SilentError
if finalErr == nil && entry.State != authEntryStateSuccess {
finalErr = cmdutil.SilentError
}
if opts.Active {
@ -240,28 +267,46 @@ func statusRun(opts *StatusOptions) error {
active: false,
gitProtocol: gitProtocol,
hostname: hostname,
showToken: opts.ShowToken,
token: token,
tokenSource: tokenSource,
username: username,
})
statuses[hostname] = append(statuses[hostname], entry)
statuses.Hosts[hostname] = append(statuses.Hosts[hostname], entry)
if err == nil && !isValidEntry(entry) {
err = cmdutil.SilentError
if finalErr == nil && entry.State != authEntryStateSuccess {
finalErr = cmdutil.SilentError
}
}
}
if !opts.ShowToken {
for _, host := range statuses.Hosts {
for i := range host {
if opts.Exporter != nil {
// In machine-readable we just drop the token
host[i].Token = ""
} else {
host[i].Token = maskToken(host[i].Token)
}
}
}
}
if opts.Exporter != nil {
// In machine-friendly mode, we always exit with no error.
opts.Exporter.Write(opts.IO, statuses)
return nil
}
prevEntry := false
for _, hostname := range hostnames {
entries, ok := statuses[hostname]
entries, ok := statuses.Hosts[hostname]
if !ok {
continue
}
stream := stdout
if err != nil {
if finalErr != nil {
stream = stderr
}
@ -270,22 +315,22 @@ func statusRun(opts *StatusOptions) error {
}
prevEntry = true
fmt.Fprintf(stream, "%s\n", cs.Bold(hostname))
fmt.Fprintf(stream, "%s", strings.Join(entries.Strings(cs), "\n"))
for i, entry := range entries {
fmt.Fprintf(stream, "%s", entry.String(cs))
if i < len(entries)-1 {
fmt.Fprint(stream, "\n")
}
}
}
return err
return finalErr
}
func displayToken(token string, printRaw bool) string {
if printRaw {
return token
}
func maskToken(token string) string {
if idx := strings.LastIndexByte(token, '_'); idx > -1 {
prefix := token[0 : idx+1]
return prefix + strings.Repeat("*", len(token)-len(prefix))
}
return strings.Repeat("*", len(token))
}
@ -308,37 +353,39 @@ type buildEntryOptions struct {
active bool
gitProtocol string
hostname string
showToken bool
token string
tokenSource string
username string
}
func buildEntry(httpClient *http.Client, opts buildEntryOptions) Entry {
tokenIsWriteable := authTokenWriteable(opts.tokenSource)
if opts.tokenSource == "oauth_token" {
func buildEntry(httpClient *http.Client, opts buildEntryOptions) authEntry {
tokenSource := opts.tokenSource
if tokenSource == "oauth_token" {
// The go-gh function TokenForHost returns this value as source for tokens read from the
// config file, but we want the file path instead. This attempts to reconstruct it.
opts.tokenSource = filepath.Join(config.ConfigDir(), "hosts.yml")
tokenSource = filepath.Join(config.ConfigDir(), "hosts.yml")
}
entry := authEntry{
Active: opts.active,
Host: opts.hostname,
Login: opts.username,
TokenSource: tokenSource,
Token: opts.token,
GitProtocol: opts.gitProtocol,
}
// If token is not writeable, then it came from an environment variable and
// we need to fetch the username as it won't be stored in the config.
if !tokenIsWriteable {
if !authTokenWriteable(tokenSource) {
// The httpClient will automatically use the correct token here as
// the token from the environment variable take highest precedence.
apiClient := api.NewClientFromHTTP(httpClient)
var err error
opts.username, err = api.CurrentLoginName(apiClient, opts.hostname)
entry.Login, err = api.CurrentLoginName(apiClient, opts.hostname)
if err != nil {
return invalidTokenEntry{
active: opts.active,
host: opts.hostname,
user: opts.username,
tokenIsWriteable: tokenIsWriteable,
tokenSource: opts.tokenSource,
}
entry.State = authEntryStateError
entry.Error = err.Error()
return entry
}
}
@ -347,39 +394,21 @@ func buildEntry(httpClient *http.Client, opts buildEntryOptions) Entry {
if err != nil {
var networkError net.Error
if errors.As(err, &networkError) && networkError.Timeout() {
return timeoutErrorEntry{
active: opts.active,
host: opts.hostname,
user: opts.username,
tokenSource: opts.tokenSource,
}
entry.State = authEntryStateTimeout
entry.Error = err.Error()
return entry
}
return invalidTokenEntry{
active: opts.active,
host: opts.hostname,
user: opts.username,
tokenIsWriteable: tokenIsWriteable,
tokenSource: opts.tokenSource,
}
entry.State = authEntryStateError
entry.Error = err.Error()
return entry
}
entry.Scopes = scopesHeader
return validEntry{
active: opts.active,
gitProtocol: opts.gitProtocol,
host: opts.hostname,
scopes: scopesHeader,
token: displayToken(opts.token, opts.showToken),
tokenSource: opts.tokenSource,
user: opts.username,
}
entry.State = authEntryStateSuccess
return entry
}
func authTokenWriteable(src string) bool {
return !strings.HasSuffix(src, "_TOKEN")
}
func isValidEntry(entry Entry) bool {
_, ok := entry.(validEntry)
return ok
}

View file

@ -3,6 +3,7 @@ package status
import (
"bytes"
"context"
"encoding/json"
"net/http"
"path/filepath"
"strings"
@ -14,6 +15,7 @@ import (
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/jsonfieldstest"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -78,14 +80,23 @@ func Test_NewCmdStatus(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname)
assert.Equal(t, tt.wants.ShowToken, gotOpts.ShowToken)
assert.Equal(t, tt.wants.Active, gotOpts.Active)
})
}
}
func TestJSONFields(t *testing.T) {
jsonfieldstest.ExpectCommandToSupportJSONFields(t, NewCmdStatus, []string{
"hosts",
})
}
func Test_statusRun(t *testing.T) {
tests := []struct {
name string
opts StatusOptions
jsonFields []string
env map[string]string
httpStubs func(*httpmock.Registry)
cfgStubs func(*testing.T, gh.Config)
@ -528,16 +539,185 @@ func Test_statusRun(t *testing.T) {
- To forget about this account, run: gh auth logout -h ghe.io -u monalisa-ghe-2
`),
},
{
name: "json, no tokens",
opts: StatusOptions{},
jsonFields: []string{"hosts"},
wantOut: "{\"hosts\":{}}\n",
wantErrOut: "You are not logged into any GitHub hosts. To log in, run: gh auth login\n",
wantErr: nil, // should not return error in machine-readable mode
},
{
name: "json, no token for given --hostname",
opts: StatusOptions{
Hostname: "foo.com",
},
jsonFields: []string{"hosts"},
cfgStubs: func(t *testing.T, c gh.Config) {
login(t, c, "github.com", "monalisa", "gho_abc123", "https")
},
wantOut: "{\"hosts\":{}}\n",
wantErrOut: "You are not logged into any accounts on foo.com\n",
wantErr: nil, // should not return error in machine-readable mode
},
{
name: "json, all valid tokens",
opts: StatusOptions{},
jsonFields: []string{"hosts"},
cfgStubs: func(t *testing.T, c gh.Config) {
login(t, c, "github.com", "monalisa", "gho_abc123", "https")
login(t, c, "github.com", "monalisa2", "gho_abc123", "https")
login(t, c, "ghe.io", "monalisa-ghe", "gho_abc123", "https")
},
httpStubs: func(reg *httpmock.Registry) {
// mock for HeaderHasMinimumScopes api requests to github.com
reg.Register(
httpmock.REST("GET", ""),
httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", "repo, read:org"))
reg.Register(
httpmock.REST("GET", ""),
httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", "repo, read:org"))
// mock for HeaderHasMinimumScopes api requests to a non-github.com host
reg.Register(
httpmock.REST("GET", "api/v3/"),
httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", "repo, read:org"))
},
wantOut: `{"hosts":{"ghe.io":[{"state":"success","active":true,"host":"ghe.io","login":"monalisa-ghe","tokenSource":"GH_CONFIG_DIR/hosts.yml","scopes":"repo, read:org","gitProtocol":"https"}],"github.com":[{"state":"success","active":true,"host":"github.com","login":"monalisa2","tokenSource":"GH_CONFIG_DIR/hosts.yml","scopes":"repo, read:org","gitProtocol":"https"},{"state":"success","active":false,"host":"github.com","login":"monalisa","tokenSource":"GH_CONFIG_DIR/hosts.yml","scopes":"repo, read:org","gitProtocol":"https"}]}}` + "\n",
},
{
name: "json, all valid tokens with hostname",
opts: StatusOptions{
Hostname: "github.com",
},
jsonFields: []string{"hosts"},
cfgStubs: func(t *testing.T, c gh.Config) {
login(t, c, "github.com", "monalisa", "gho_abc123", "https")
login(t, c, "github.com", "monalisa2", "gho_abc123", "https")
login(t, c, "ghe.io", "monalisa-ghe", "gho_abc123", "https")
},
httpStubs: func(reg *httpmock.Registry) {
// mocks for HeaderHasMinimumScopes api requests to github.com
reg.Register(
httpmock.REST("GET", ""),
httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", "repo, read:org"))
reg.Register(
httpmock.REST("GET", ""),
httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", "repo, read:org"))
},
wantOut: `{"hosts":{"github.com":[{"state":"success","active":true,"host":"github.com","login":"monalisa2","tokenSource":"GH_CONFIG_DIR/hosts.yml","scopes":"repo, read:org","gitProtocol":"https"},{"state":"success","active":false,"host":"github.com","login":"monalisa","tokenSource":"GH_CONFIG_DIR/hosts.yml","scopes":"repo, read:org","gitProtocol":"https"}]}}` + "\n",
},
{
name: "json, all valid tokens with active",
opts: StatusOptions{
Active: true,
},
jsonFields: []string{"hosts"},
cfgStubs: func(t *testing.T, c gh.Config) {
login(t, c, "github.com", "monalisa", "gho_abc123", "https")
login(t, c, "github.com", "monalisa2", "gho_abc123", "https")
login(t, c, "ghe.io", "monalisa-ghe", "gho_abc123", "https")
},
httpStubs: func(reg *httpmock.Registry) {
// mocks for HeaderHasMinimumScopes api requests to github.com
reg.Register(
httpmock.REST("GET", ""),
httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", "repo, read:org"))
reg.Register(
httpmock.REST("GET", "api/v3/"),
httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", "repo, read:org"))
},
wantOut: `{"hosts":{"ghe.io":[{"state":"success","active":true,"host":"ghe.io","login":"monalisa-ghe","tokenSource":"GH_CONFIG_DIR/hosts.yml","scopes":"repo, read:org","gitProtocol":"https"}],"github.com":[{"state":"success","active":true,"host":"github.com","login":"monalisa2","tokenSource":"GH_CONFIG_DIR/hosts.yml","scopes":"repo, read:org","gitProtocol":"https"}]}}` + "\n",
},
{
name: "json, token from env",
opts: StatusOptions{},
jsonFields: []string{"hosts"},
env: map[string]string{"GH_TOKEN": "gho_abc123"},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("GET", ""),
httpmock.ScopesResponder(""))
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"monalisa"}}}`))
},
wantOut: `{"hosts":{"github.com":[{"state":"success","active":true,"host":"github.com","login":"monalisa","tokenSource":"GH_TOKEN","gitProtocol":"https"}]}}` + "\n",
},
{
name: "json, bad token",
opts: StatusOptions{},
jsonFields: []string{"hosts"},
cfgStubs: func(t *testing.T, c gh.Config) {
login(t, c, "ghe.io", "monalisa-ghe", "gho_abc123", "https")
},
httpStubs: func(reg *httpmock.Registry) {
// mock for HeaderHasMinimumScopes api requests to a non-github.com host
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(400, "no bueno"))
},
wantOut: `{"hosts":{"ghe.io":[{"state":"error","error":"HTTP 400 (https://ghe.io/api/v3/)","active":true,"host":"ghe.io","login":"monalisa-ghe","tokenSource":"GH_CONFIG_DIR/hosts.yml","gitProtocol":"https"}]}}` + "\n",
wantErr: nil, // should not return error in machine-readable mode
},
{
name: "json, bad token from env",
opts: StatusOptions{},
jsonFields: []string{"hosts"},
env: map[string]string{"GH_TOKEN": "gho_abc123"},
httpStubs: func(reg *httpmock.Registry) {
// mock for HeaderHasMinimumScopes api requests to a non-github.com host
reg.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StatusStringResponse(400, `no bueno`))
},
wantOut: `{"hosts":{"github.com":[{"state":"error","error":"non-200 OK status code: body: \"no bueno\"","active":true,"host":"github.com","login":"","tokenSource":"GH_TOKEN","gitProtocol":"https"}]}}` + "\n",
wantErr: nil, // should not return error in machine-readable mode
},
{
name: "json, timeout error",
opts: StatusOptions{
Hostname: "github.com",
},
jsonFields: []string{"hosts"},
cfgStubs: func(t *testing.T, c gh.Config) {
login(t, c, "github.com", "monalisa", "abc123", "https")
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", ""), func(req *http.Request) (*http.Response, error) {
// timeout error
return nil, context.DeadlineExceeded
})
},
wantOut: `{"hosts":{"github.com":[{"state":"timeout","error":"Get \"https://api.github.com/\": context deadline exceeded","active":true,"host":"github.com","login":"monalisa","tokenSource":"GH_CONFIG_DIR/hosts.yml","gitProtocol":"https"}]}}` + "\n",
wantErr: nil, // should not return error in machine-readable mode
},
{
name: "json, with show token",
opts: StatusOptions{
Hostname: "github.com",
ShowToken: true,
},
jsonFields: []string{"hosts"},
cfgStubs: func(t *testing.T, c gh.Config) {
login(t, c, "github.com", "monalisa", "abc123", "https")
},
httpStubs: func(reg *httpmock.Registry) {
// mocks for HeaderHasMinimumScopes api requests to github.com
reg.Register(
httpmock.REST("GET", ""),
httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", "repo, read:org"))
},
wantOut: `{"hosts":{"github.com":[{"state":"success","active":true,"host":"github.com","login":"monalisa","tokenSource":"GH_CONFIG_DIR/hosts.yml","token":"abc123","scopes":"repo, read:org","gitProtocol":"https"}]}}` + "\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdinTTY(true)
ios.SetStderrTTY(true)
ios.SetStdoutTTY(true)
tt.opts.IO = ios
cfg, _ := config.NewIsolatedTestConfig(t)
if tt.cfgStubs != nil {
tt.cfgStubs(t, cfg)
@ -555,6 +735,12 @@ func Test_statusRun(t *testing.T) {
tt.httpStubs(reg)
}
if tt.jsonFields != nil {
jsonExporter := cmdutil.NewJSONExporter()
jsonExporter.SetFields(tt.jsonFields)
tt.opts.Exporter = jsonExporter
}
for k, v := range tt.env {
t.Setenv(k, v)
}
@ -565,8 +751,9 @@ func Test_statusRun(t *testing.T) {
} else {
require.NoError(t, err)
}
output := strings.ReplaceAll(stdout.String(), config.ConfigDir()+string(filepath.Separator), "GH_CONFIG_DIR/")
errorOutput := strings.ReplaceAll(stderr.String(), config.ConfigDir()+string(filepath.Separator), "GH_CONFIG_DIR/")
output := replaceAll(stdout.String(), config.ConfigDir()+string(filepath.Separator), "GH_CONFIG_DIR/")
errorOutput := replaceAll(stderr.String(), config.ConfigDir()+string(filepath.Separator), "GH_CONFIG_DIR/")
require.Equal(t, tt.wantErrOut, errorOutput)
require.Equal(t, tt.wantOut, output)
@ -574,8 +761,24 @@ func Test_statusRun(t *testing.T) {
}
}
func login(t *testing.T, c gh.Config, hostname, username, protocol, token string) {
func login(t *testing.T, c gh.Config, hostname, username, token, protocol string) {
t.Helper()
_, err := c.Authentication().Login(hostname, username, protocol, token, false)
_, err := c.Authentication().Login(hostname, username, token, protocol, false)
require.NoError(t, err)
}
// replaceAll replaces all instances of old with new in s, as well as all instances
// of the JSON-escaped version of old with the JSON-escaped version of new.
// This is because when the test is run on Windows the paths will have backslashes
// escaped in JSON and a simple strings.ReplaceAll won't catch them.
func replaceAll(s string, old string, new string) string {
jsonEscapedOld, _ := json.Marshal(old)
jsonEscapedOld = jsonEscapedOld[1 : len(jsonEscapedOld)-1]
jsonEscapedNew, _ := json.Marshal(new)
jsonEscapedNew = jsonEscapedNew[1 : len(jsonEscapedNew)-1]
replaced := strings.ReplaceAll(s, string(jsonEscapedOld), string(jsonEscapedNew))
replaced = strings.ReplaceAll(replaced, old, new)
return replaced
}

View file

@ -25,9 +25,33 @@ type JSONFlagError struct {
func AddJSONFlags(cmd *cobra.Command, exportTarget *Exporter, fields []string) {
f := cmd.Flags()
addJsonFlag(f)
addJqFlag(f, "q")
addTemplateFlag(f, "t")
setupJsonFlags(cmd, exportTarget, fields)
}
func AddJSONFlagsWithoutShorthand(cmd *cobra.Command, exportTarget *Exporter, fields []string) {
f := cmd.Flags()
addJsonFlag(f)
addJqFlag(f, "")
addTemplateFlag(f, "")
setupJsonFlags(cmd, exportTarget, fields)
}
func addJsonFlag(f *pflag.FlagSet) {
f.StringSlice("json", nil, "Output JSON with the specified `fields`")
f.StringP("jq", "q", "", "Filter JSON output using a jq `expression`")
f.StringP("template", "t", "", "Format JSON output using a Go template; see \"gh help formatting\"")
}
func addJqFlag(f *pflag.FlagSet, shorthand string) {
f.StringP("jq", shorthand, "", "Filter JSON output using a jq `expression`")
}
func addTemplateFlag(f *pflag.FlagSet, shorthand string) {
f.StringP("template", shorthand, "", "Format JSON output using a Go template; see \"gh help formatting\"")
}
func setupJsonFlags(cmd *cobra.Command, exportTarget *Exporter, fields []string) {
_ = cmd.RegisterFlagCompletionFunc("json", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
var results []string

View file

@ -119,6 +119,44 @@ func TestAddJSONFlags(t *testing.T) {
}
}
func TestAddJSONFlagsWithoutShorthand(t *testing.T) {
tests := []struct {
name string
setFlags func(cmd *cobra.Command)
wantFlags map[string]string
}{
{
name: "no conflicting flags",
setFlags: func(cmd *cobra.Command) {
cmd.Flags().StringP("web", "w", "", "")
cmd.Flags().StringP("token", "t", "", "")
},
wantFlags: map[string]string{
"web": "w",
"token": "t",
"jq": "",
"template": "",
"json": "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Run: func(*cobra.Command, []string) {}}
tt.setFlags(cmd)
AddJSONFlagsWithoutShorthand(cmd, nil, []string{})
for f, shorthand := range tt.wantFlags {
flag := cmd.Flags().Lookup(f)
require.NotNil(t, flag)
require.Equal(t, shorthand, flag.Shorthand)
}
})
}
}
// TestAddJSONFlagsSetsAnnotations asserts that `AddJSONFlags` function adds the
// appropriate annotation to the command, which could later be used by doc
// generator functions.