diff --git a/.golangci.yaml b/.golangci.yaml index 3105f8e..6d09f75 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -9,6 +9,7 @@ linters: - unconvert - unused - vet + - gci run: timeout: 5m diff --git a/README.md b/README.md index 75c2ea2..6acaeb1 100644 --- a/README.md +++ b/README.md @@ -34,17 +34,16 @@ Available Commands: bake Use a pizza-oven to source git commits into OpenSauced completion Generate the autocompletion script for the specified shell help Help about any command + insights Gather insights about git contributors, repositories, users and pull requests login Log into the CLI application via GitHub repo-query Ask questions about a GitHub repository version Displays the build version of the CLI Flags: - --beta Shorthand for using the beta OpenSauced API endpoint - ("https://beta.api.opensauced.pizza/v1"). Superceds the - '--endpoint' flag - -e, --endpoint string The API endpoint to send requests to (default - "https://api.opensauced.pizza/v1") - -h, --help help for pizza + --beta Shorthand for using the beta OpenSauced API endpoint ("https://beta.api.opensauced.pizza"). Supersedes the '--endpoint' flag + --disable-telemetry Disable sending telemetry data to OpenSauced + -e, --endpoint string The API endpoint to send requests to (default "https://api.opensauced.pizza") + -h, --help help for pizza Use "pizza [command] --help" for more information about a command. ``` diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index fc5df52..3c921bd 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -33,9 +33,12 @@ type Options struct { telemetry *utils.PosthogCliClient } -const loginLongDesc string = `Log into OpenSauced. +const ( + sessionFileName = "session.json" + loginLongDesc = `Log into OpenSauced. This command initiates the GitHub auth flow to log you into the OpenSauced application by launching your browser` +) func NewLoginCommand() *cobra.Command { opts := &Options{} @@ -48,7 +51,7 @@ func NewLoginCommand() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { username, err := run() - disableTelem, _ := cmd.Flags().GetBool("disable-telemetry") + disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) if !disableTelem { opts.telemetry = utils.NewPosthogCliClient() @@ -121,7 +124,7 @@ func run() (string, error) { return } - filePath := path.Join(dirName, constants.SessionFileName) + filePath := path.Join(dirName, sessionFileName) if err := os.WriteFile(filePath, jsonData, 0o600); err != nil { http.Error(w, "Error writing to file", http.StatusInternalServerError) return diff --git a/cmd/bake/bake.go b/cmd/bake/bake.go index 78c805c..0cd7d99 100644 --- a/cmd/bake/bake.go +++ b/cmd/bake/bake.go @@ -3,34 +3,31 @@ package bake import ( - "bytes" - "encoding/json" + "context" "errors" "fmt" - "io" - "net/http" - "os" + "sync" + "github.com/open-sauced/go-api/client" "github.com/open-sauced/pizza-cli/pkg/api" + "github.com/open-sauced/pizza-cli/pkg/constants" "github.com/open-sauced/pizza-cli/pkg/utils" "github.com/spf13/cobra" - - "gopkg.in/yaml.v3" ) // Options are the options for the pizza bake command including user // defined configurations type Options struct { - // The API Client for the calls to bake git repos - APIClient *api.Client + // APIClient is the http client for making calls to the open-sauced api + APIClient *client.APIClient - // URLs are the git repo URLs that will be sourced via 'pizza bake' - URLs []string + // Repos is the array of git repository urls + Repos []string // Wait defines the client choice to wait for /bake to finish processing Wait bool - // FilePath is the location of the file containing a batch of repos to be baked + // FilePath is the path to yaml file containing an array of git repository urls FilePath string // telemetry for capturing CLI events @@ -39,7 +36,7 @@ type Options struct { const bakeLongDesc string = `WARNING: Proof of concept feature. -The bake command accepts one or multiple URLs to a git repository and uses a pizza-oven service +The bake command accepts one or multiple Repos to a git repository and uses a pizza-oven service to source those commits. These commits will then be used for insights on OpenSauced.` // NewBakeCommand returns a new cobra command for 'pizza bake' @@ -47,119 +44,78 @@ func NewBakeCommand() *cobra.Command { opts := &Options{} cmd := &cobra.Command{ - Use: "bake url [flags]", + Use: "bake url... [flags]", Short: "Use a pizza-oven to source git commits into OpenSauced", Long: bakeLongDesc, Args: func(cmd *cobra.Command, args []string) error { - if len(args) == 0 && opts.FilePath == "" { - return errors.New("must specify the URL(s) of a git repository or provide a batch file") + fileFlag := cmd.Flags().Lookup(constants.FlagNameFile) + if !fileFlag.Changed && len(args) == 0 { + return fmt.Errorf("must specify git repository url argument(s) or provide %s flag", fileFlag.Name) } + opts.Repos = append(opts.Repos, args...) return nil }, RunE: func(cmd *cobra.Command, args []string) error { - disableTelem, _ := cmd.Flags().GetBool("disable-telemetry") - endpoint, _ := cmd.Flags().GetString("endpoint") - useBeta, _ := cmd.Flags().GetBool("beta") - - if useBeta { - fmt.Printf("Using beta API endpoint - %s\n", api.BetaAPIEndpoint) - endpoint = api.BetaAPIEndpoint - } - opts.APIClient = api.NewClient(endpoint) - - opts.URLs = append(opts.URLs, args...) + endpointURL, _ := cmd.Flags().GetString(constants.FlagNameEndpoint) + opts.APIClient = api.NewGoClient(endpointURL) + disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) if !disableTelem { opts.telemetry = utils.NewPosthogCliClient() defer opts.telemetry.Done() - opts.telemetry.CaptureBake(opts.URLs) + opts.telemetry.CaptureBake(opts.Repos) } return run(opts) }, } - - cmd.Flags().BoolVarP(&opts.Wait, "wait", "w", false, "Wait for bake processing to finish") - cmd.Flags().StringVarP(&opts.FilePath, "file", "f", "", "The yaml file containing a series of repos to batch to /bake") - + cmd.Flags().StringVarP(&opts.FilePath, constants.FlagNameFile, "f", "", "Path to yaml file containing an array of git repository urls") + cmd.Flags().BoolVarP(&opts.Wait, constants.FlagNameWait, "w", false, "Wait for bake processing to finish") return cmd } -type bakePostRequest struct { - URL string `json:"url"` - Wait bool `json:"wait"` -} - -type repos struct { - URLs []string `yaml:"repos"` -} - func run(opts *Options) error { - var repos repos - uniqueURLs := make(map[string]bool) - - if opts.FilePath != "" { - configFile, err := os.ReadFile(opts.FilePath) - if err != nil { - return err - } - - err = yaml.Unmarshal(configFile, &repos) - if err != nil { - return err - } - - for _, url := range repos.URLs { - uniqueURLs[url] = true - } + repositories, err := utils.HandleRepositoryValues(opts.Repos, opts.FilePath) + if err != nil { + return err } - - // make sure there are no duplicated queries to the same URL - for _, url := range opts.URLs { - if _, ok := uniqueURLs[url]; !ok { - uniqueURLs[url] = true - continue - } - fmt.Printf("Warning: duplicated URL (%s) would not be processed again\n", url) + var ( + waitGroup = new(sync.WaitGroup) + errorChan = make(chan error, len(repositories)) + ) + for url := range repositories { + waitGroup.Add(1) + go func(repoURL string) { + defer waitGroup.Done() + err = bakeRepository(context.TODO(), opts.APIClient, repoURL, opts.Wait) + if err != nil { + errorChan <- err + return + } + fmt.Println("successfully baked repository", repoURL) + }(url) } - - for url := range uniqueURLs { - bodyPostReq := bakePostRequest{ - URL: url, - Wait: opts.Wait, - } - - err := bakeRepo(bodyPostReq, opts.APIClient) - if err != nil { - fmt.Printf("Error: failed fetch of %s repository (%s)\n", url, err.Error()) - } + waitGroup.Wait() + close(errorChan) + var allErrors error + for err = range errorChan { + allErrors = errors.Join(allErrors, err) } - - return nil + return allErrors } -func bakeRepo(bodyPostReq bakePostRequest, apiClient *api.Client) error { - bodyPostJSON, err := json.Marshal(bodyPostReq) - if err != nil { - return err +func bakeRepository(ctx context.Context, apiClient *client.APIClient, repoURL string, wait bool) error { + body := client.BakeRepoDto{ + Url: repoURL, + Wait: wait, } - - responseBody := bytes.NewBuffer(bodyPostJSON) - resp, err := apiClient.HTTPClient.Post(fmt.Sprintf("%s/bake", apiClient.Endpoint), "application/json", responseBody) + _, err := apiClient.PizzaOvenServiceAPI. + BakeARepositoryWithThePizzaOvenMicroservice(ctx). + BakeRepoDto(body). + Execute() if err != nil { - return err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - - fmt.Printf("Resp body: %v\n", string(body)) + return fmt.Errorf("error while calling 'PizzaOvenServiceAPI.BakeARepositoryWithThePizzaOvenMicroservice' with repository %q: %w", repoURL, err) } - return nil } diff --git a/cmd/bake/bake_test.go b/cmd/bake/bake_test.go index 0c5fd00..4607082 100644 --- a/cmd/bake/bake_test.go +++ b/cmd/bake/bake_test.go @@ -5,7 +5,7 @@ import ( "net/http/httptest" "testing" - "github.com/open-sauced/pizza-cli/pkg/api" + "github.com/open-sauced/go-api/client" ) func TestSendsPost(t *testing.T) { @@ -16,13 +16,13 @@ func TestSendsPost(t *testing.T) { { name: "Sends post request", opts: &Options{ - URLs: []string{"https://test.com"}, + Repos: []string{"https://test.com"}, }, }, { - name: "Sends post request with multiple URLs", + name: "Sends post request with multiple Repos", opts: &Options{ - URLs: []string{"https://test.com", "https://github.com/open-sauced/pizza", "https://github.com/open-sauced/insights"}, + Repos: []string{"https://test.com", "https://github.com/open-sauced/pizza", "https://github.com/open-sauced/insights"}, }, }, } @@ -33,7 +33,6 @@ func TestSendsPost(t *testing.T) { if r.Method != http.MethodPost { t.Fail() } - // Always return an ok status with a dummy body from the mock server w.WriteHeader(http.StatusOK) _, err := w.Write([]byte("body")) @@ -43,9 +42,9 @@ func TestSendsPost(t *testing.T) { } })) defer testServer.Close() - - tt.opts.APIClient = api.NewClient(testServer.URL) - + configuration := client.NewConfiguration() + configuration.Servers = client.ServerConfigurations{{URL: testServer.URL}} + tt.opts.APIClient = client.NewAPIClient(configuration) err := run(tt.opts) if err != nil { t.Fail() diff --git a/cmd/insights/contributors.go b/cmd/insights/contributors.go new file mode 100644 index 0000000..cb87ad7 --- /dev/null +++ b/cmd/insights/contributors.go @@ -0,0 +1,271 @@ +package insights + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "sync" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/lipgloss" + "github.com/open-sauced/go-api/client" + "github.com/open-sauced/pizza-cli/pkg/api" + "github.com/open-sauced/pizza-cli/pkg/constants" + "github.com/open-sauced/pizza-cli/pkg/utils" + "github.com/spf13/cobra" +) + +type Options struct { + // APIClient is the http client for making calls to the open-sauced api + APIClient *client.APIClient + + // Repos is the array of git repository urls + Repos []string + + // FilePath is the path to yaml file containing an array of git repository urls + FilePath string + + // Period is the number of days, used for query filtering + Period int32 +} + +// NewContributorsCommand returns a new cobra command for 'pizza insights contributors' +func NewContributorsCommand() *cobra.Command { + opts := &Options{} + cmd := &cobra.Command{ + Use: "contributors [flags]", + Short: "Gather insights about contributors of indexed git repositories", + Long: "Gather insights about contributors of indexed git repositories. This command will show new, recent, alumni, repeat contributors for each git repository", + Args: func(cmd *cobra.Command, args []string) error { + fileFlag := cmd.Flags().Lookup(constants.FlagNameFile) + if !fileFlag.Changed && len(args) == 0 { + return fmt.Errorf("must specify git repository url argument(s) or provide %s flag", fileFlag.Name) + } + opts.Repos = append(opts.Repos, args...) + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + endpointURL, _ := cmd.Flags().GetString(constants.FlagNameEndpoint) + opts.APIClient = api.NewGoClient(endpointURL) + return run(opts) + }, + } + cmd.Flags().StringVarP(&opts.FilePath, constants.FlagNameFile, "f", "", "Path to yaml file containing an array of git repository urls") + cmd.Flags().Int32VarP(&opts.Period, constants.FlagNamePeriod, "p", 30, "Number of days, used for query filtering") + return cmd +} + +func run(opts *Options) error { + repositories, err := utils.HandleRepositoryValues(opts.Repos, opts.FilePath) + if err != nil { + return err + } + var ( + waitGroup = new(sync.WaitGroup) + errorChan = make(chan error, len(repositories)) + ) + for url := range repositories { + repoURL := url + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + repoContributorsInsights, err := findAllRepositoryContributorsInsights(context.TODO(), opts, repoURL) + if err != nil { + errorChan <- err + return + } + repoContributorsInsights.RenderTable() + }() + } + waitGroup.Wait() + close(errorChan) + var allErrors error + for err = range errorChan { + allErrors = errors.Join(allErrors, err) + } + return allErrors +} + +type repositoryContributorsInsights struct { + RepoID int32 + RepoURL string + New []string + Recent []string + Alumni []string + Repeat []string +} + +func (rci *repositoryContributorsInsights) RenderTable() { + if rci == nil { + return + } + rows := []table.Row{ + {"New contributors", strconv.Itoa(len(rci.New))}, + {"Recent contributors", strconv.Itoa(len(rci.Recent))}, + {"Alumni contributors", strconv.Itoa(len(rci.Alumni))}, + {"Repeat contributors", strconv.Itoa(len(rci.Repeat))}, + } + columns := []table.Column{ + { + Title: "Repository URL", + Width: 20, + }, + { + Title: rci.RepoURL, + Width: len(rci.RepoURL), + }, + } + styles := table.DefaultStyles() + styles.Header.MarginTop(1) + styles.Selected = lipgloss.NewStyle() + repoTable := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithHeight(len(rows)), + table.WithStyles(styles), + ) + fmt.Println(repoTable.View()) +} + +func findRepositoryByOwnerAndRepoName(ctx context.Context, apiClient *client.APIClient, repoURL string) (*client.DbRepo, error) { + owner, repoName, err := utils.GetOwnerAndRepoFromURL(repoURL) + if err != nil { + return nil, fmt.Errorf("could not extract owner and repo from url: %w", err) + } + repo, response, err := apiClient.RepositoryServiceAPI.FindOneByOwnerAndRepo(ctx, owner, repoName).Execute() + if err != nil { + if response != nil && response.StatusCode == http.StatusNotFound { + message := fmt.Sprintf("repository %s is either non-existent or has not been indexed yet", repoURL) + fmt.Println("ignoring repository issue:", message) + return nil, nil + } + return nil, fmt.Errorf("error while calling 'RepositoryServiceAPI.FindOneByOwnerAndRepo' with owner %q and repo %q: %w", owner, repoName, err) + } + return repo, nil +} + +func findAllRepositoryContributorsInsights(ctx context.Context, opts *Options, repoURL string) (*repositoryContributorsInsights, error) { + repo, err := findRepositoryByOwnerAndRepoName(ctx, opts.APIClient, repoURL) + if err != nil { + return nil, fmt.Errorf("could not get contributors insights for repository %s: %w", repoURL, err) + } + if repo == nil { + return nil, nil + } + repoContributorsInsights := &repositoryContributorsInsights{ + RepoID: repo.Id, + RepoURL: repoURL, + } + var ( + waitGroup = new(sync.WaitGroup) + errorChan = make(chan error, 4) + ) + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + response, err := findNewRepositoryContributors(ctx, opts.APIClient, repo.Id, opts.Period) + if err != nil { + errorChan <- err + return + } + for _, data := range response.Data { + repoContributorsInsights.New = append(repoContributorsInsights.New, data.AuthorLogin) + } + }() + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + response, err := findRecentRepositoryContributors(ctx, opts.APIClient, repo.Id, opts.Period) + if err != nil { + errorChan <- err + return + } + for _, data := range response.Data { + repoContributorsInsights.Recent = append(repoContributorsInsights.Recent, data.AuthorLogin) + } + }() + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + response, err := findAlumniRepositoryContributors(ctx, opts.APIClient, repo.Id, opts.Period) + if err != nil { + errorChan <- err + return + } + for _, data := range response.Data { + repoContributorsInsights.Alumni = append(repoContributorsInsights.Alumni, data.AuthorLogin) + } + }() + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + response, err := findRepeatRepositoryContributors(ctx, opts.APIClient, repo.Id, opts.Period) + if err != nil { + errorChan <- err + return + } + for _, data := range response.Data { + repoContributorsInsights.Repeat = append(repoContributorsInsights.Repeat, data.AuthorLogin) + } + }() + waitGroup.Wait() + close(errorChan) + if len(errorChan) > 0 { + var allErrors error + for err = range errorChan { + allErrors = errors.Join(allErrors, err) + } + return nil, allErrors + } + return repoContributorsInsights, nil +} + +func findNewRepositoryContributors(ctx context.Context, apiClient *client.APIClient, repoID, period int32) (*client.SearchAllPullRequestContributors200Response, error) { + data, _, err := apiClient.ContributorsServiceAPI. + NewPullRequestContributors(ctx). + RepoIds(fmt.Sprintf("%d", repoID)). + Range_(period). + Execute() + if err != nil { + return nil, fmt.Errorf("error while calling 'ContributorsServiceAPI.NewPullRequestContributors' with repository %d': %w", repoID, err) + } + return data, nil +} + +func findRecentRepositoryContributors(ctx context.Context, apiClient *client.APIClient, repoID, period int32) (*client.SearchAllPullRequestContributors200Response, error) { + data, _, err := apiClient.ContributorsServiceAPI. + FindAllRecentPullRequestContributors(ctx). + RepoIds(fmt.Sprintf("%d", repoID)). + Range_(period). + Execute() + if err != nil { + return nil, fmt.Errorf("error while calling 'ContributorsServiceAPI.FindAllRecentPullRequestContributors' with repository %d': %w", repoID, err) + } + return data, nil +} + +func findAlumniRepositoryContributors(ctx context.Context, apiClient *client.APIClient, repoID, period int32) (*client.SearchAllPullRequestContributors200Response, error) { + data, _, err := apiClient.ContributorsServiceAPI. + FindAllChurnPullRequestContributors(ctx). + RepoIds(fmt.Sprintf("%d", repoID)). + Range_(period). + Execute() + if err != nil { + return nil, fmt.Errorf("error while calling 'ContributorsServiceAPI.FindAllChurnPullRequestContributors' with repository %d': %w", repoID, err) + } + return data, nil +} + +func findRepeatRepositoryContributors(ctx context.Context, apiClient *client.APIClient, repoID, period int32) (*client.SearchAllPullRequestContributors200Response, error) { + data, _, err := apiClient.ContributorsServiceAPI. + FindAllRepeatPullRequestContributors(ctx). + RepoIds(fmt.Sprintf("%d", repoID)). + Range_(period). + Execute() + if err != nil { + return nil, fmt.Errorf("error while calling 'ContributorsServiceAPI.FindAllRepeatPullRequestContributors' with repository %d: %w", repoID, err) + } + return data, nil +} diff --git a/cmd/insights/insights.go b/cmd/insights/insights.go new file mode 100644 index 0000000..2ab442f --- /dev/null +++ b/cmd/insights/insights.go @@ -0,0 +1,19 @@ +package insights + +import ( + "github.com/spf13/cobra" +) + +// NewInsightsCommand returns a new cobra command for 'pizza insights' +func NewInsightsCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "insights [flags]", + Short: "Gather insights about git contributors, repositories, users and pull requests", + Long: "Gather insights about git contributors, repositories, user and pull requests and display the results", + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + cmd.AddCommand(NewContributorsCommand()) + return cmd +} diff --git a/cmd/repo-query/repo-query.go b/cmd/repo-query/repo-query.go index 00dad80..ff27edd 100644 --- a/cmd/repo-query/repo-query.go +++ b/cmd/repo-query/repo-query.go @@ -13,12 +13,11 @@ import ( "strings" "github.com/open-sauced/pizza-cli/pkg/api" + "github.com/open-sauced/pizza-cli/pkg/constants" "github.com/open-sauced/pizza-cli/pkg/utils" "github.com/spf13/cobra" ) -const repoQueryURL string = "https://opensauced.tools" - type Options struct { APIClient *api.Client @@ -53,25 +52,22 @@ func NewRepoQueryCommand() *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - disableTelem, _ := cmd.Flags().GetBool("disable-telemetry") - endpoint, _ := cmd.Flags().GetString("endpoint") - useBeta, _ := cmd.Flags().GetBool("beta") - + endpointURL, _ := cmd.Flags().GetString(constants.FlagNameEndpoint) + useBeta, _ := cmd.Flags().GetBool(constants.FlagNameBeta) if useBeta { - fmt.Printf("Warning!! Using beta API endpoint not supported for repo-query - using: %s\n", endpoint) + fmt.Printf("Warning!! Using beta API endpoint not supported for repo-query - using: %s\n", endpointURL) } // The repo-query is currently not deployed behind "api.opensauced.pizza" // So, if the user has not changed the desired "endpoint", use the default // tools URL to send SSE to the repo-query engine - if endpoint == api.APIEndpoint { - endpoint = repoQueryURL + if endpointURL == constants.EndpointProd { + endpointURL = constants.EndpointTools } - - opts.APIClient = api.NewClient(endpoint) - + opts.APIClient = api.NewClient(endpointURL) opts.URL = args[0] + disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) if !disableTelem { opts.telemetry = utils.NewPosthogCliClient() defer opts.telemetry.Done() @@ -98,33 +94,11 @@ func newRepoQueryAgent(apiClient *api.Client) *repoQueryAgent { } } -func (rq *repoQueryAgent) getOwnerAndRepo(url string) (owner, repo string, err error) { - if !strings.HasPrefix(url, "https://github.com/") { - return "", "", fmt.Errorf("invalid URL: %s", url) - } - - // Remove the "https://github.com/" prefix from the URL - url = strings.TrimPrefix(url, "https://github.com/") - - // Split the remaining URL path into segments - segments := strings.Split(url, "/") - - // The first segment is the owner, and the second segment is the repository name - if len(segments) >= 2 { - owner = segments[0] - repo = segments[1] - } else { - return "", "", fmt.Errorf("invalid URL: %s", url) - } - - return owner, repo, nil -} - func run(opts *Options) error { agent := newRepoQueryAgent(opts.APIClient) // get repo name and owner name from URL - owner, repo, err := agent.getOwnerAndRepo(opts.URL) + owner, repo, err := utils.GetOwnerAndRepoFromURL(opts.URL) if err != nil { return err } diff --git a/cmd/root/root.go b/cmd/root/root.go index e19a6da..af1a376 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -4,13 +4,13 @@ package root import ( "fmt" - "github.com/spf13/cobra" - "github.com/open-sauced/pizza-cli/cmd/auth" "github.com/open-sauced/pizza-cli/cmd/bake" + "github.com/open-sauced/pizza-cli/cmd/insights" repoquery "github.com/open-sauced/pizza-cli/cmd/repo-query" "github.com/open-sauced/pizza-cli/cmd/version" - "github.com/open-sauced/pizza-cli/pkg/api" + "github.com/open-sauced/pizza-cli/pkg/constants" + "github.com/spf13/cobra" ) // NewRootCommand bootstraps a new root cobra command for the pizza CLI @@ -20,15 +20,26 @@ func NewRootCommand() (*cobra.Command, error) { Short: "OpenSauced CLI", Long: `A command line utility for insights, metrics, and all things OpenSauced`, RunE: run, + Args: func(cmd *cobra.Command, args []string) error { + betaFlag := cmd.Flags().Lookup(constants.FlagNameBeta) + if betaFlag.Changed { + err := cmd.Flags().Lookup(constants.FlagNameEndpoint).Value.Set(constants.EndpointBeta) + if err != nil { + return err + } + } + return nil + }, } - cmd.PersistentFlags().StringP("endpoint", "e", api.APIEndpoint, "The API endpoint to send requests to") - cmd.PersistentFlags().Bool("beta", false, fmt.Sprintf("Shorthand for using the beta OpenSauced API endpoint (\"%s\"). Superceds the '--endpoint' flag", api.BetaAPIEndpoint)) - cmd.PersistentFlags().Bool("disable-telemetry", false, "Disable sending telemetry data to OpenSauced") + cmd.PersistentFlags().StringP(constants.FlagNameEndpoint, "e", constants.EndpointProd, "The API endpoint to send requests to") + cmd.PersistentFlags().Bool(constants.FlagNameBeta, false, fmt.Sprintf("Shorthand for using the beta OpenSauced API endpoint (\"%s\"). Supersedes the '--%s' flag", constants.EndpointBeta, constants.FlagNameEndpoint)) + cmd.PersistentFlags().Bool(constants.FlagNameTelemetry, false, "Disable sending telemetry data to OpenSauced") cmd.AddCommand(bake.NewBakeCommand()) cmd.AddCommand(repoquery.NewRepoQueryCommand()) cmd.AddCommand(auth.NewLoginCommand()) + cmd.AddCommand(insights.NewInsightsCommand()) cmd.AddCommand(version.NewVersionCommand()) return cmd, nil diff --git a/go.mod b/go.mod index 64f3be1..d536864 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,10 @@ module github.com/open-sauced/pizza-cli go 1.20 require ( + github.com/charmbracelet/bubbles v0.16.1 + github.com/charmbracelet/lipgloss v0.7.1 github.com/cli/browser v1.2.0 + github.com/open-sauced/go-api/client v0.0.0-20230825180028-30fe67759eff github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 @@ -12,7 +15,21 @@ require ( ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbletea v0.24.1 // indirect + github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect github.com/google/uuid v1.3.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.18 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/reflow v0.3.0 // indirect + github.com/muesli/termenv v0.15.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.9.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index 35b5fbe..faaf92a 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,47 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5WdeuYSY= +github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc= +github.com/charmbracelet/bubbletea v0.24.1 h1:LpdYfnu+Qc6XtvMz6d/6rRY71yttHTP5HtrjMgWvixc= +github.com/charmbracelet/bubbletea v0.24.1/go.mod h1:rK3g/2+T8vOSEkNHvtq40umJpeVYDn6bLaqbgzhL/hg= +github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= +github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= github.com/cli/browser v1.2.0 h1:yvU7e9qf97kZqGFX6n2zJPHsmSObY9ske+iCvKelvXg= github.com/cli/browser v1.2.0/go.mod h1:xFFnXLVcAyW9ni0cuo6NnrbCP75JxJ0RO7VtCBiH/oI= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY= +github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= +github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= +github.com/open-sauced/go-api/client v0.0.0-20230825180028-30fe67759eff h1:sHfgCRtAg3xzNXoqqJ2MhDLwPj4GhC99ksDYC+K1BtM= +github.com/open-sauced/go-api/client v0.0.0-20230825180028-30fe67759eff/go.mod h1:W/TRuLUqYpMvkmElDUQvQ07xlxhK8TOfpwRh8SCAuNA= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69 h1:01dHVodha5BzrMtVmcpPeA4VYbZEsTXQ6m4123zQXJk= github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69/go.mod h1:migYMxlAqcnQy+3eN8mcL0b2tpKy6R+8Zc0lxwk4dKM= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= @@ -18,11 +50,17 @@ github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRM github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/urfave/cli v1.22.5/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= golang.org/x/term v0.9.0/go.mod h1:M6DEAAIenWoTxdKrOltXcmDY3rSplQUkrvaDU5FcQyo= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/main.go b/main.go index fd2c210..8ceebdf 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "log" + "os" "github.com/open-sauced/pizza-cli/cmd/root" "github.com/open-sauced/pizza-cli/pkg/utils" @@ -12,11 +13,9 @@ func main() { if err != nil { log.Fatal(err) } - utils.SetupRootCommand(rootCmd) - err = rootCmd.Execute() if err != nil { - log.Fatal(err) + os.Exit(1) } } diff --git a/pkg/api/client.go b/pkg/api/client.go index e519936..b2bdfd6 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -1,13 +1,17 @@ package api -import "net/http" +import ( + "net/http" + + "github.com/open-sauced/go-api/client" +) type Client struct { // The configured http client for making API requests HTTPClient *http.Client // The API endpoint to use when making requests - // Example: https://api.opensauced.pizza or https://beta.api.opensauced.pizza + // Example: https://api.opensauced.pizza Endpoint string } @@ -18,3 +22,13 @@ func NewClient(endpoint string) *Client { Endpoint: endpoint, } } + +func NewGoClient(endpoint string) *client.APIClient { + configuration := client.NewConfiguration() + configuration.Servers = client.ServerConfigurations{ + { + URL: endpoint, + }, + } + return client.NewAPIClient(configuration) +} diff --git a/pkg/api/constants.go b/pkg/api/constants.go deleted file mode 100644 index 66a71e7..0000000 --- a/pkg/api/constants.go +++ /dev/null @@ -1,6 +0,0 @@ -package api - -const ( - APIEndpoint = "https://api.opensauced.pizza/v1" - BetaAPIEndpoint = "https://beta.api.opensauced.pizza/v1" -) diff --git a/pkg/constants/api.go b/pkg/constants/api.go new file mode 100644 index 0000000..6d17b07 --- /dev/null +++ b/pkg/constants/api.go @@ -0,0 +1,7 @@ +package constants + +const ( + EndpointProd = "https://api.opensauced.pizza" + EndpointBeta = "https://beta.api.opensauced.pizza" + EndpointTools = "https://opensauced.tools" +) diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go deleted file mode 100644 index 3d7b92e..0000000 --- a/pkg/constants/constants.go +++ /dev/null @@ -1,5 +0,0 @@ -package constants - -const ( - SessionFileName = "session.json" -) diff --git a/pkg/constants/flags.go b/pkg/constants/flags.go new file mode 100644 index 0000000..6feef7b --- /dev/null +++ b/pkg/constants/flags.go @@ -0,0 +1,10 @@ +package constants + +const ( + FlagNameBeta = "beta" + FlagNameEndpoint = "endpoint" + FlagNameFile = "file" + FlagNamePeriod = "period" + FlagNameTelemetry = "disable-telemetry" + FlagNameWait = "wait" +) diff --git a/pkg/utils/usage.go b/pkg/constants/templates.go similarity index 59% rename from pkg/utils/usage.go rename to pkg/constants/templates.go index 64a5afd..6aa9fc4 100644 --- a/pkg/utils/usage.go +++ b/pkg/constants/templates.go @@ -1,15 +1,14 @@ -package utils +package constants -import ( - "os" +const ( + HelpTemplate = ` +{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}} - "github.com/spf13/pflag" - "golang.org/x/term" -) +{{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}` -// Identical to the default cobra usage template, -// but utilizes wrappedFlagUsages to ensure flag usages don't wrap around -var usageTemplate = `Usage:{{if .Runnable}} + // UsageTemplate is identical to the default cobra usage template, + // but utilizes wrappedFlagUsages to ensure flag usages don't wrap around + UsageTemplate = `Usage:{{if .Runnable}} {{.UseLine}}{{end}}{{if gt (len .Aliases) 0}} Aliases: @@ -32,17 +31,4 @@ Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}} ` - -// Uses the users terminal size or width of 80 if cannot determine users width -func wrappedFlagUsages(cmd *pflag.FlagSet) string { - fd := int(os.Stdout.Fd()) - width := 80 - - // Get the terminal width and dynamically set - termWidth, _, err := term.GetSize(fd) - if err == nil { - width = termWidth - } - - return cmd.FlagUsagesWrapped(width - 1) -} +) diff --git a/pkg/utils/arguments.go b/pkg/utils/arguments.go new file mode 100644 index 0000000..bcef797 --- /dev/null +++ b/pkg/utils/arguments.go @@ -0,0 +1,29 @@ +package utils + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +func HandleRepositoryValues(repos []string, filePath string) (map[string]struct{}, error) { + uniqueRepoURLs := make(map[string]struct{}) + for _, repo := range repos { + uniqueRepoURLs[repo] = struct{}{} + } + if filePath != "" { + file, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + var reposFromYaml []string + err = yaml.Unmarshal(file, &reposFromYaml) + if err != nil { + return nil, err + } + for _, repo := range reposFromYaml { + uniqueRepoURLs[repo] = struct{}{} + } + } + return uniqueRepoURLs, nil +} diff --git a/pkg/utils/github.go b/pkg/utils/github.go new file mode 100644 index 0000000..83a8ac1 --- /dev/null +++ b/pkg/utils/github.go @@ -0,0 +1,28 @@ +package utils + +import ( + "fmt" + "strings" +) + +func GetOwnerAndRepoFromURL(url string) (owner, repo string, err error) { + if !strings.HasPrefix(url, "https://github.com/") { + return "", "", fmt.Errorf("invalid URL: %s", url) + } + + // Remove the "https://github.com/" prefix from the URL + url = strings.TrimPrefix(url, "https://github.com/") + + // Split the remaining URL path into segments + segments := strings.Split(url, "/") + + // The first segment is the owner, and the second segment is the repository name + if len(segments) >= 2 { + owner = segments[0] + repo = segments[1] + } else { + return "", "", fmt.Errorf("invalid URL: %s", url) + } + + return owner, repo, nil +} diff --git a/pkg/utils/help.go b/pkg/utils/help.go deleted file mode 100644 index cba848b..0000000 --- a/pkg/utils/help.go +++ /dev/null @@ -1,6 +0,0 @@ -package utils - -var helpTemplate = ` -{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}} - -{{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}` diff --git a/pkg/utils/root.go b/pkg/utils/root.go index 4c92471..c0411c8 100644 --- a/pkg/utils/root.go +++ b/pkg/utils/root.go @@ -1,12 +1,32 @@ package utils -import "github.com/spf13/cobra" +import ( + "os" -// SetupRootCommand is a convinence utility for applying templates and nice + "github.com/open-sauced/pizza-cli/pkg/constants" + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "golang.org/x/term" +) + +// SetupRootCommand is a convenience utility for applying templates and nice // user experience pieces to the root cobra command func SetupRootCommand(rootCmd *cobra.Command) { cobra.AddTemplateFunc("wrappedFlagUsages", wrappedFlagUsages) + rootCmd.SetUsageTemplate(constants.UsageTemplate) + rootCmd.SetHelpTemplate(constants.HelpTemplate) +} + +// Uses the users terminal size or width of 80 if cannot determine users width +func wrappedFlagUsages(cmd *pflag.FlagSet) string { + fd := int(os.Stdout.Fd()) + width := 80 + + // Get the terminal width and dynamically set + termWidth, _, err := term.GetSize(fd) + if err == nil { + width = termWidth + } - rootCmd.SetUsageTemplate(usageTemplate) - rootCmd.SetHelpTemplate(helpTemplate) + return cmd.FlagUsagesWrapped(width - 1) }