Merge pull request #11544 from benjlevesque/auth-status-json-output-8635
Add JSON output to `gh auth status`
This commit is contained in:
commit
b76bc7706a
4 changed files with 455 additions and 161 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue