diff --git a/Makefile b/Makefile index f3b289b0e..792e6a805 100644 --- a/Makefile +++ b/Makefile @@ -32,3 +32,13 @@ cover: .PHONY: lint lint: gometalinter --deadline 60s --vendor ./... + +.PHONY: doc +doc: + godoc -http=:6060 + +.PHONY: dev +dev: + go get golang.org/x/tools/cmd/godoc + go get -u github.com/alecthomas/gometalinter + gometalinter --install diff --git a/README.md b/README.md index 1dcb82fe0..43475461a 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,30 @@ To make sure dependencies are installed: $ dep ensure ``` +## Linting + +We use [`gometalinter`](github.com/alecthomas/gometalinter) for linting. + +You can install it via `$ make dev` or manually with: + +``` +$ go get -u github.com/alecthomas/gometalinter +$ gometalinter --install +``` + +Then you can run it with `$ make lint`. + ## Known Issues * ... ## Doc -You can view `godoc` of cli in your browser. +You can view `godoc` of the cli in your browser. + +After installing it either via `go get golang.org/x/tools/cmd/godoc` or running `make dev`. -1. Run `godoc -http=:6060` +1. Run `make doc` or `godoc -http=:6060` 2. Access http://localhost:6060/pkg/github.com/circleci/circleci-cli/ ## Editor support diff --git a/client/client.go b/client/client.go new file mode 100644 index 000000000..299140a82 --- /dev/null +++ b/client/client.go @@ -0,0 +1,43 @@ +package client + +import ( + "context" + + "github.com/circleci/circleci-cli/logger" + "github.com/machinebox/graphql" +) + +// Client wraps a graphql.Client and other fields for making API calls. +type Client struct { + endpoint string + token string + client *graphql.Client + logger *logger.Logger +} + +// NewClient returns a reference to a Client. +// We also call graphql.NewClient to initialize a new GraphQL Client. +// Then we pass the Logger originally constructed as cmd.Logger. +func NewClient(endpoint string, token string, logger *logger.Logger) *Client { + return &Client{ + endpoint, + token, + graphql.NewClient(endpoint), + logger, + } +} + +// Run will construct a request using graphql.NewRequest. +// Then it will execute the given query using graphql.Client.Run. +// This function will return the unmarshalled response as JSON. +func (c *Client) Run(query string) (map[string]interface{}, error) { + req := graphql.NewRequest(query) + req.Header.Set("Authorization", c.token) + + ctx := context.Background() + var resp map[string]interface{} + + c.logger.Debug("Querying %s with:\n\n%s\n\n", c.endpoint, query) + err := c.client.Run(ctx, req, &resp) + return resp, err +} diff --git a/cmd/diagnostic.go b/cmd/diagnostic.go index 97def2c99..bfb0c1437 100644 --- a/cmd/diagnostic.go +++ b/cmd/diagnostic.go @@ -1,8 +1,6 @@ package cmd import ( - "fmt" - "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -14,21 +12,18 @@ var diagnosticCmd = &cobra.Command{ } func diagnostic(cmd *cobra.Command, args []string) { - host := viper.GetString("host") + endpoint := viper.GetString("endpoint") token := viper.GetString("token") - fmt.Printf("\n---\nCircleCI CLI Diagnostics\n---\n\n") - fmt.Printf("Config found: `%v`\n", viper.ConfigFileUsed()) + Logger.Infoln("\n---\nCircleCI CLI Diagnostics\n---\n") + Logger.Infof("Config found: `%v`\n", viper.ConfigFileUsed()) - if host == "host" || host == "" { - fmt.Println("Please set a host!") - } else { - fmt.Printf("Host is: %s\n", host) - } + Logger.Infof("GraphQL API endpoint: %s\n", endpoint) if token == "token" || token == "" { - fmt.Println("Please set a token!") + var err error + Logger.FatalOnError("Please set a token!", err) } else { - fmt.Println("OK, got a token.") + Logger.Infoln("OK, got a token.") } } diff --git a/cmd/query.go b/cmd/query.go index cb28b0eda..345c2e411 100644 --- a/cmd/query.go +++ b/cmd/query.go @@ -1,12 +1,11 @@ package cmd import ( - "context" "encoding/json" - "fmt" - "log" + "io/ioutil" + "os" - "github.com/machinebox/graphql" + "github.com/circleci/circleci-cli/client" "github.com/spf13/cobra" "github.com/spf13/viper" ) @@ -18,50 +17,15 @@ var queryCmd = &cobra.Command{ } func query(cmd *cobra.Command, args []string) { - host := viper.GetString("host") - token := viper.GetString("token") - client := graphql.NewClient(host + "/graphql") + client := client.NewClient(viper.GetString("endpoint"), viper.GetString("token"), Logger) - query := ` - query IntrospectionQuery { - __schema { - queryType { name } - mutationType { name } - subscriptionType { name } - types { - ...FullType - } - directives { - name - description - } - } - } - - fragment FullType on __Type { - kind - name - description - fields(includeDeprecated: true) { - name - } - }` - - req := graphql.NewRequest(query) - req.Header.Set("Authorization", token) - - ctx := context.Background() - var resp map[string]interface{} - - fmt.Print("Querying ", host, " with:\n\n", query, "\n\n") - if err := client.Run(ctx, req, &resp); err != nil { - log.Fatal(err) - } + query, err := ioutil.ReadAll(os.Stdin) + Logger.FatalOnError("Something happened", err) + resp, err := client.Run(string(query)) + Logger.FatalOnError("Something happend", err) b, err := json.MarshalIndent(resp, "", " ") - if err != nil { - log.Fatalln("Could not parse graphql response", err.Error()) - } - fmt.Print("Result: \n\n") - fmt.Println(string(b)) + Logger.FatalOnError("Could not parse graphql response", err) + + Logger.Infoln(string(b)) } diff --git a/cmd/root.go b/cmd/root.go index e84207177..4abe71dbe 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,14 +2,14 @@ package cmd import ( "fmt" - "log" "os" + "github.com/circleci/circleci-cli/logger" "github.com/spf13/cobra" "github.com/spf13/viper" ) -// Execute adds all child commands to RootCmd and +// Execute adds all child commands to rootCmd and // sets flags appropriately. This function is called // by main.main(). It only needs to happen once to // the RootCmd. @@ -27,32 +27,32 @@ var rootCmd = &cobra.Command{ } var ( + verbose bool cfgFile string cfgName = "cli" cfgPathDefault = fmt.Sprintf("%s/.circleci/%s.yml", os.Getenv("HOME"), cfgName) ) +// Logger is exposed here so we can access it from subcommands. +// This allows us to print to the log at anytime from within the `cmd` package. +var Logger *logger.Logger + func addCommands() { rootCmd.AddCommand(diagnosticCmd) rootCmd.AddCommand(queryCmd) } -func fatalOnError(msg string, err error) { - if err == nil { - return - } - log.Fatalln(msg, err.Error()) -} - func init() { cobra.OnInitialize(initConfig) + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging.") + Logger = logger.NewLogger(verbose) rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file (default is $HOME/.circleci/cli.yml)") - rootCmd.PersistentFlags().StringP("host", "H", "https://circleci.com", "the host of your CircleCI install") + rootCmd.PersistentFlags().StringP("endpoint", "e", "https://circleci.com/graphql", "the endpoint of your CircleCI GraphQL API") rootCmd.PersistentFlags().StringP("token", "t", "", "your token for using CircleCI") - fatalOnError("Error binding host flag", viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("host"))) - fatalOnError("Error binding token flag", viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))) + Logger.FatalOnError("Error binding endpoint flag", viper.BindPFlag("endpoint", rootCmd.PersistentFlags().Lookup("endpoint"))) + Logger.FatalOnError("Error binding token flag", viper.BindPFlag("token", rootCmd.PersistentFlags().Lookup("token"))) } // TODO: move config stuff to it's own package @@ -61,10 +61,10 @@ func initConfig() { return } - fatalOnError("Error creating a new config file", createConfig()) + Logger.FatalOnError("Error creating a new config file", createConfig()) cfgFile = cfgPathDefault - fatalOnError( + Logger.FatalOnError( "Failed to re-read config after creating a new file", readConfig(), // reload config after creating it ) @@ -81,7 +81,7 @@ func readConfig() (err error) { } // read in environment variables that match - // set a prefix for config, i.e. CIRCLECI_CLI_HOST + // set a prefix for config, i.e. CIRCLECI_CLI_ENDPOINT viper.SetEnvPrefix("circleci_cli") viper.AutomaticEnv() @@ -93,18 +93,18 @@ func readConfig() (err error) { func createConfig() (err error) { // Don't support creating config at --config flag, only default if cfgFile != "" { - fmt.Printf("Setting up default config at: %v\n", cfgPathDefault) + Logger.Debug("Setting up default config at: %v\n", cfgPathDefault) } path := fmt.Sprintf("%s/.circleci", os.Getenv("HOME")) if _, err = os.Stat(path); os.IsNotExist(err) { - fatalOnError( + Logger.FatalOnError( fmt.Sprintf("Error creating directory: '%s'", path), os.Mkdir(path, 0644), ) } else { - fatalOnError(fmt.Sprintf("Error accessing '%s'", path), err) + Logger.FatalOnError(fmt.Sprintf("Error accessing '%s'", path), err) } // Create default config file @@ -115,39 +115,31 @@ func createConfig() (err error) { // open file with read & write file, err := os.OpenFile(cfgPathDefault, os.O_RDWR, 0600) if err != nil { - fmt.Println(err.Error()) - os.Exit(-1) + Logger.FatalOnError("", err) } defer func() { - fatalOnError("Error closing config file", file.Close()) + Logger.FatalOnError("Error closing config file", file.Close()) }() // read flag values - host := viper.GetString("host") + endpoint := viper.GetString("endpoint") token := viper.GetString("token") - if host == "host" || host == "" { - fmt.Print("Please enter the HTTP(S) host of your CircleCI installation:") - fmt.Scanln(&host) - fmt.Println("OK.") - } - if token == "token" || token == "" { - fmt.Print("Please enter your CircleCI API token: ") + Logger.Info("Please enter your CircleCI API token: ") fmt.Scanln(&token) - fmt.Println("OK.") + Logger.Infoln("OK.") } // format input - configValues := fmt.Sprintf("host: %v\ntoken: %v\n", host, token) + configValues := fmt.Sprintf("endpoint: %v\ntoken: %v\n", endpoint, token) // write new config values to file if _, err = file.WriteString(configValues); err != nil { - fmt.Println(err.Error()) - os.Exit(-1) + Logger.FatalOnError("", err) } - fmt.Printf("Your configuration has been created in `%v`.\n", cfgPathDefault) - fmt.Println("It can edited manually for advanced settings.") + Logger.Info("Your configuration has been created in `%v`.\n", cfgPathDefault) + Logger.Infoln("It can edited manually for advanced settings.") return err } diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 000000000..f46910999 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,73 @@ +package logger + +import ( + "log" + "os" +) + +// Logger wraps a few log.Logger instances in private fields. +// They are accessible via their respective methods. +type Logger struct { + debug *log.Logger + info *log.Logger + error *log.Logger + verbose bool +} + +// NewLogger returns a reference to a Logger. +// We usually call this when initializing cmd.Logger. +// Later we pass this to client.NewClient so it can also log. +// By default debug and error go to os.Stderr, and info goes to os.Stdout +func NewLogger(verbose bool) *Logger { + return &Logger{ + log.New(os.Stderr, "", 0), + log.New(os.Stdout, "", 0), + log.New(os.Stderr, "", 0), + verbose, + } +} + +// Debug prints a formatted message to stderr only if verbose is set. +// Consider these messages useful for developers of the CLI. +// This method wraps log.Logger.Printf +func (l *Logger) Debug(format string, args ...interface{}) { + if l.verbose { + l.debug.Printf(format, args...) + } +} + +// Info prints all args to os.Stdout +// It's commonly used for messages we want to show the user. +// This method wraps log.Logger.Print +func (l *Logger) Info(args ...interface{}) { + l.info.Print(args...) +} + +// Infoln prints all args to os.Stdout followed by a newline. +// This method wraps log.Logger.Println +func (l *Logger) Infoln(args ...interface{}) { + l.info.Println(args...) +} + +// Infof prints a formatted message to stdout +// This method wraps log.Logger.Printf +func (l *Logger) Infof(format string, args ...interface{}) { + l.info.Printf(format, args...) +} + +// Error prints a message and the given error's message to os.Stderr +// This method wraps log.Logger.Print +func (l *Logger) Error(msg string, err error) { + if err != nil { + l.error.Print(msg, err.Error()) + } +} + +// FatalOnError prints a message and error's message to os.Stderr then QUITS! +// Please be aware this method will exit the program via os.Exit(1). +// This method wraps log.Logger.Fatalln +func (l *Logger) FatalOnError(msg string, err error) { + if err != nil { + l.error.Fatalln(msg, err.Error()) + } +} diff --git a/testquery.gql b/testquery.gql new file mode 100644 index 000000000..004df04bf --- /dev/null +++ b/testquery.gql @@ -0,0 +1,23 @@ + query IntrospectionQuery { + __schema { + queryType { name } + mutationType { name } + subscriptionType { name } + types { + ...FullType + } + directives { + name + description + } + } + } + + fragment FullType on __Type { + kind + name + description + fields(includeDeprecated: true) { + name + } + } \ No newline at end of file