diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index 73ee084e9..348b9531d 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -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 -} diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 3f16baf46..4246b1e86 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -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 +} diff --git a/pkg/cmdutil/json_flags.go b/pkg/cmdutil/json_flags.go index 596c2f216..579d38552 100644 --- a/pkg/cmdutil/json_flags.go +++ b/pkg/cmdutil/json_flags.go @@ -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 diff --git a/pkg/cmdutil/json_flags_test.go b/pkg/cmdutil/json_flags_test.go index 63c63aa00..ee089960b 100644 --- a/pkg/cmdutil/json_flags_test.go +++ b/pkg/cmdutil/json_flags_test.go @@ -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.