From d16091ff4ee2ad74e025779b27321897d2c8a49c Mon Sep 17 00:00:00 2001
From: Tsvetoslav Dimov <32335835+cecobask@users.noreply.github.com>
Date: Thu, 7 Sep 2023 00:21:06 +0200
Subject: [PATCH] feat: provide repository contributors insights (#30)

---
 .golangci.yaml                                |   1 +
 README.md                                     |  11 +-
 cmd/auth/auth.go                              |   9 +-
 cmd/bake/bake.go                              | 152 ++++------
 cmd/bake/bake_test.go                         |  15 +-
 cmd/insights/contributors.go                  | 271 ++++++++++++++++++
 cmd/insights/insights.go                      |  19 ++
 cmd/repo-query/repo-query.go                  |  44 +--
 cmd/root/root.go                              |  23 +-
 go.mod                                        |  17 ++
 go.sum                                        |  38 +++
 main.go                                       |   5 +-
 pkg/api/client.go                             |  18 +-
 pkg/api/constants.go                          |   6 -
 pkg/constants/api.go                          |   7 +
 pkg/constants/constants.go                    |   5 -
 pkg/constants/flags.go                        |  10 +
 .../usage.go => constants/templates.go}       |  32 +--
 pkg/utils/arguments.go                        |  29 ++
 pkg/utils/github.go                           |  28 ++
 pkg/utils/help.go                             |   6 -
 pkg/utils/root.go                             |  28 +-
 22 files changed, 569 insertions(+), 205 deletions(-)
 create mode 100644 cmd/insights/contributors.go
 create mode 100644 cmd/insights/insights.go
 delete mode 100644 pkg/api/constants.go
 create mode 100644 pkg/constants/api.go
 delete mode 100644 pkg/constants/constants.go
 create mode 100644 pkg/constants/flags.go
 rename pkg/{utils/usage.go => constants/templates.go} (59%)
 create mode 100644 pkg/utils/arguments.go
 create mode 100644 pkg/utils/github.go
 delete mode 100644 pkg/utils/help.go

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 <command> [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)
 }