diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..b5a4788 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +@open-sauced/engineering diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bf1ffb8..4543068 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -19,11 +19,13 @@ jobs: with: app_id: ${{ secrets.OS_GITHUB_APP_ID }} private_key: ${{ secrets.OS_GITHUB_APP_PRIVATE_KEY }} + - name: "☁️ checkout repository" uses: actions/checkout@v3 with: fetch-depth: 0 - + token: ${{ steps.generate_token.outputs.token }} + - name: "πŸ”§ setup node" uses: actions/setup-node@v3 with: @@ -43,7 +45,6 @@ jobs: outputs: release-tag: ${{ steps.semantic-release.outputs.release-tag }} - build: needs: - release @@ -59,7 +60,7 @@ jobs: - name: Set up Go uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 with: - go-version: 1.21 + go-version: 1.21.x - name: Check out code uses: actions/checkout@v3 @@ -68,5 +69,11 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build -ldflags="-s -w" -o build/pizza-${{ matrix.goos }}-${{ matrix.goarch }} + GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} go build \ + -ldflags="-s -w" \ + -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${{ secrets.POSTHOG_WRITE_PUBLIC_KEY }}'" \ + -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Version={{ needs.release.outputs.release-tag }}'" \ + -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=$(git rev-parse HEAD)'" \ + -o build/pizza-${{ matrix.goos }}-${{ matrix.goarch }} gh release upload ${{ needs.release.outputs.release-tag }} build/pizza-${{ matrix.goos }}-${{ matrix.goarch }} + diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 0a9c29e..9b7fcbe 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -23,11 +23,11 @@ jobs: - name: Set up Go uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 with: - go-version: 1.20.x + go-version: 1.21.x - name: golangci-lint uses: golangci/golangci-lint-action@v3 with: - version: v1.53 + version: v1.54.2 test: runs-on: ubuntu-latest @@ -36,7 +36,7 @@ jobs: - name: Set up Go uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 with: - go-version: 1.20.x + go-version: 1.21.x - name: Test run: make test @@ -47,6 +47,6 @@ jobs: - name: Set up Go uses: actions/setup-go@fac708d6674e30b6ba41289acaab6d4b75aa0753 # v4.0.1 with: - go-version: 1.20.x + go-version: 1.21.x - name: Build go binary run: make build 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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d2b09b2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,72 @@ +# πŸ“¦ open-sauced/pizza-cli changelog + +[![conventional commits](https://img.shields.io/badge/conventional%20commits-1.0.0-yellow.svg)](https://conventionalcommits.org) +[![semantic versioning](https://img.shields.io/badge/semantic%20versioning-2.0.0-green.svg)](https://semver.org) + +> All notable changes to this project will be documented in this file + +## [1.0.0-beta.7](https://github.com/open-sauced/pizza-cli/compare/v1.0.0-beta.6...v1.0.0-beta.7) (2023-10-02) + + +### πŸ• Features + +* provide repository insights ([#38](https://github.com/open-sauced/pizza-cli/issues/38)) ([dc148d6](https://github.com/open-sauced/pizza-cli/commit/dc148d6fe17b9aa96ad6951aefd0a7fd7cf0e160)) + +## [1.0.0-beta.6](https://github.com/open-sauced/pizza-cli/compare/v1.0.0-beta.5...v1.0.0-beta.6) (2023-09-27) + + +### πŸ› Bug Fixes + +* Uses correct generated token when checking out cli repo in release ([#44](https://github.com/open-sauced/pizza-cli/issues/44)) ([1e0c9f1](https://github.com/open-sauced/pizza-cli/commit/1e0c9f1ef3c9d0d9bd7f590f6bec021707f4c833)) + +## [1.0.0-beta.5](https://github.com/open-sauced/pizza-cli/compare/v1.0.0-beta.4...v1.0.0-beta.5) (2023-09-27) + + +### πŸ• Features + +* Pizza show ([#24](https://github.com/open-sauced/pizza-cli/issues/24)) ([72f21ce](https://github.com/open-sauced/pizza-cli/commit/72f21ce260ec73c3ea0d7e97ed1411a86bb1d753)) + +## [1.0.0-beta.4](https://github.com/open-sauced/pizza-cli/compare/v1.0.0-beta.3...v1.0.0-beta.4) (2023-09-06) + + +### πŸ• Features + +* provide repository contributors insights ([#30](https://github.com/open-sauced/pizza-cli/issues/30)) ([d16091f](https://github.com/open-sauced/pizza-cli/commit/d16091ff4ee2ad74e025779b27321897d2c8a49c)) + +## [1.0.0-beta.3](https://github.com/open-sauced/pizza-cli/compare/v1.0.0-beta.2...v1.0.0-beta.3) (2023-08-31) + + +### πŸ• Features + +* Version command for CLI based on release builds ([#36](https://github.com/open-sauced/pizza-cli/issues/36)) ([9f3eedc](https://github.com/open-sauced/pizza-cli/commit/9f3eedcf7dac1d72f91ae40a5a09df3ee341a99c)) + +## [1.0.0-beta.2](https://github.com/open-sauced/pizza-cli/compare/v1.0.0-beta.1...v1.0.0-beta.2) (2023-08-29) + + +### πŸ• Features + +* Add posthog telemetry integration ([#37](https://github.com/open-sauced/pizza-cli/issues/37)) ([9829f49](https://github.com/open-sauced/pizza-cli/commit/9829f499dad0651ec97d0969e040d2acc30714e0)) + +## 1.0.0-beta.1 (2023-08-22) + + +### πŸ€– Build System + +* sematic bin release, npm ([7b4607e](https://github.com/open-sauced/pizza-cli/commit/7b4607e9a4aa5eba0b5f163c586520c1022494ee)) + + +### πŸ• Features + +* Add install instructions and script for pizza CLI ([#26](https://github.com/open-sauced/pizza-cli/issues/26)) ([421a429](https://github.com/open-sauced/pizza-cli/commit/421a429ed99cca957365106485da97e085b0f173)) +* cli auth ([#21](https://github.com/open-sauced/pizza-cli/issues/21)) ([34728fb](https://github.com/open-sauced/pizza-cli/commit/34728fb62d01b746ffc8ede3c97a090b32b0b9f9)) +* GitHub action to build and upload Go artifacts after release created ([#22](https://github.com/open-sauced/pizza-cli/issues/22)) ([ad187a9](https://github.com/open-sauced/pizza-cli/commit/ad187a9f3229e41785a09130132a799378c04528)) +* Http Client for accessing OpenSauced API client ([#23](https://github.com/open-sauced/pizza-cli/issues/23)) ([ec2b357](https://github.com/open-sauced/pizza-cli/commit/ec2b35789a2864d38bf63e0ec1a3b68393a34e9b)) +* Leverage the GITHUB_APP_TOKEN for releases ([#32](https://github.com/open-sauced/pizza-cli/issues/32)) ([e0a25e0](https://github.com/open-sauced/pizza-cli/commit/e0a25e003e89a7a5173ecaae12366922365243c9)) +* npm i -g pizza ([73291d1](https://github.com/open-sauced/pizza-cli/commit/73291d13d632b709f2583d834aefe6ad758de8d7)) +* repo-query support ([199cfd7](https://github.com/open-sauced/pizza-cli/commit/199cfd7b04e1e1683cce5abc08c57bbef01644f6)) +* update bin name release.yaml ([6b21cb8](https://github.com/open-sauced/pizza-cli/commit/6b21cb84f88f75467ce6f270e136dfca5e462d23)) + + +### πŸ” Continuous Integration + +* Update @open-sauced/release@2.2.1 and compliance.yaml ([#33](https://github.com/open-sauced/pizza-cli/issues/33)) ([146b6b7](https://github.com/open-sauced/pizza-cli/commit/146b6b7485a0f33090a4ccefd23624f9aa0df085)) diff --git a/Makefile b/Makefile index 0db730e..c1da7b6 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ lint: --rm \ -v ./:/app \ -w /app \ - golangci/golangci-lint:v1.53.3 \ + golangci/golangci-lint:v1.54.2 \ golangci-lint run -v test: diff --git a/README.md b/README.md index e0aba3f..f217849 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,18 @@ 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 + show Get visual metrics of a repository + version Displays the build version of the CLI Flags: - -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 + -o, --output string The formatting style for command output (default "table") Use "pizza [command] --help" for more information about a command. ``` diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 7095b17..3c921bd 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -20,34 +20,63 @@ import ( "github.com/cli/browser" "github.com/open-sauced/pizza-cli/pkg/constants" + "github.com/open-sauced/pizza-cli/pkg/utils" "github.com/spf13/cobra" ) //go:embed success.html var successHTML string -const loginLongDesc string = `Log into OpenSauced. +// Options are the persistent options for the login command +type Options struct { + // telemetry for capturing CLI events + telemetry *utils.PosthogCliClient +} + +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{} + cmd := &cobra.Command{ Use: "login", Short: "Log into the CLI application via GitHub", Long: loginLongDesc, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return run() + username, err := run() + + disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) + + if !disableTelem { + opts.telemetry = utils.NewPosthogCliClient() + defer opts.telemetry.Done() + + if err != nil { + opts.telemetry.CaptureFailedLogin() + } else { + opts.telemetry.CaptureLogin(username) + } + } + + return err }, } return cmd } -func run() error { +func run() (string, error) { + username := "" + codeVerifier, codeChallenge, err := pkce(codeChallengeLength) if err != nil { - return fmt.Errorf("PKCE error: %v", err.Error()) + return "", fmt.Errorf("PKCE error: %v", err.Error()) } supabaseAuthURL := fmt.Sprintf("https://%s.supabase.co/auth/v1/authorize", supabaseID) @@ -95,7 +124,7 @@ func run() 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 @@ -107,7 +136,7 @@ func run() error { fmt.Println("Error writing response:", err.Error()) } - username := sessionData.User.UserMetadata["user_name"] + username = sessionData.User.UserMetadata["user_name"].(string) fmt.Println("πŸŽ‰ Login successful πŸŽ‰") fmt.Println("Welcome aboard", username, "πŸ•") }) @@ -130,17 +159,17 @@ func run() error { select { case err := <-errCh: if err != nil && err != http.ErrServerClosed { - return err + return "", err } case <-time.After(60 * time.Second): shutdown(server) - return errors.New("authentication timeout") + return "", errors.New("authentication timeout") case <-interruptCh: fmt.Println("\nAuthentication interrupted❗️") shutdown(server) os.Exit(0) } - return nil + return username, nil } func getSession(authCode, codeVerifier string) (*accessTokenResponse, error) { diff --git a/cmd/bake/bake.go b/cmd/bake/bake.go index 8bc2407..0cd7d99 100644 --- a/cmd/bake/bake.go +++ b/cmd/bake/bake.go @@ -3,39 +3,40 @@ 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 + telemetry *utils.PosthogCliClient } 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' @@ -43,110 +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 { - endpoint, _ := cmd.Flags().GetString("endpoint") - useBeta, _ := cmd.Flags().GetBool("beta") + endpointURL, _ := cmd.Flags().GetString(constants.FlagNameEndpoint) + opts.APIClient = api.NewGoClient(endpointURL) + disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) - if useBeta { - fmt.Printf("Using beta API endpoint - %s\n", api.BetaAPIEndpoint) - endpoint = api.BetaAPIEndpoint + if !disableTelem { + opts.telemetry = utils.NewPosthogCliClient() + defer opts.telemetry.Done() + + opts.telemetry.CaptureBake(opts.Repos) } - opts.APIClient = api.NewClient(endpoint) - opts.URLs = append(opts.URLs, args...) 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..1420f6a --- /dev/null +++ b/cmd/insights/contributors.go @@ -0,0 +1,318 @@ +package insights + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "sync" + + bubblesTable "github.com/charmbracelet/bubbles/table" + "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 contributorsOptions 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 + + // Output is the formatting style for command output + Output string +} + +// NewContributorsCommand returns a new cobra command for 'pizza insights contributors' +func NewContributorsCommand() *cobra.Command { + opts := &contributorsOptions{} + cmd := &cobra.Command{ + Use: "contributors url... [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) + output, _ := cmd.Flags().GetString(constants.FlagNameOutput) + opts.Output = output + return opts.run(context.TODO()) + }, + } + 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 (opts *contributorsOptions) run(ctx context.Context) 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)) + insightsChan = make(chan contributorsInsights, len(repositories)) + doneChan = make(chan struct{}) + insights = make(contributorsInsightsSlice, 0, len(repositories)) + allErrors error + ) + go func() { + for url := range repositories { + repoURL := url + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + allData, err := findAllContributorsInsights(ctx, opts, repoURL) + if err != nil { + errorChan <- err + return + } + if allData == nil { + return + } + insightsChan <- *allData + }() + } + waitGroup.Wait() + close(doneChan) + }() + for { + select { + case err = <-errorChan: + allErrors = errors.Join(allErrors, err) + case data := <-insightsChan: + insights = append(insights, data) + case <-doneChan: + if allErrors != nil { + return allErrors + } + output, err := insights.BuildOutput(opts.Output) + if err != nil { + return err + } + fmt.Println(output) + return nil + } + } +} + +type contributorsInsights struct { + RepoURL string `json:"repo_url" yaml:"repo_url"` + RepoID int `json:"-" yaml:"-"` + New []string `json:"new" yaml:"new"` + Recent []string `json:"recent" yaml:"recent"` + Alumni []string `json:"alumni" yaml:"alumni"` + Repeat []string `json:"repeat" yaml:"repeat"` +} + +type contributorsInsightsSlice []contributorsInsights + +func (cis contributorsInsightsSlice) BuildOutput(format string) (string, error) { + switch format { + case constants.OutputTable: + return cis.OutputTable() + case constants.OutputJSON: + return utils.OutputJSON(cis) + case constants.OutputYAML: + return utils.OutputYAML(cis) + default: + return "", fmt.Errorf("unknown output format %s", format) + } +} + +func (cis contributorsInsightsSlice) OutputTable() (string, error) { + tables := make([]string, 0, len(cis)) + for i := range cis { + rows := []bubblesTable.Row{ + { + "New contributors", + strconv.Itoa(len(cis[i].New)), + }, + { + "Recent contributors", + strconv.Itoa(len(cis[i].Recent)), + }, + { + "Alumni contributors", + strconv.Itoa(len(cis[i].Alumni)), + }, + { + "Repeat contributors", + strconv.Itoa(len(cis[i].Repeat)), + }, + } + columns := []bubblesTable.Column{ + { + Title: "Repository URL", + Width: utils.GetMaxTableRowWidth(rows), + }, + { + Title: cis[i].RepoURL, + Width: len(cis[i].RepoURL), + }, + } + tables = append(tables, utils.OutputTable(rows, columns)) + } + separator := fmt.Sprintf("\n%s\n", strings.Repeat("―", 3)) + return strings.Join(tables, separator), nil +} + +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 findAllContributorsInsights(ctx context.Context, opts *contributorsOptions, repoURL string) (*contributorsInsights, 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 := &contributorsInsights{ + RepoID: int(repo.Id), + RepoURL: repo.SvnUrl, + } + 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..e09539d --- /dev/null +++ b/cmd/insights/insights.go @@ -0,0 +1,20 @@ +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()) + cmd.AddCommand(NewRepositoriesCommand()) + return cmd +} diff --git a/cmd/insights/repositories.go b/cmd/insights/repositories.go new file mode 100644 index 0000000..51d3b64 --- /dev/null +++ b/cmd/insights/repositories.go @@ -0,0 +1,274 @@ +package insights + +import ( + "context" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "sync" + + bubblesTable "github.com/charmbracelet/bubbles/table" + "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 repositoriesOptions 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 + // Constrained to either 30 or 60 + Period int32 + + // Output is the formatting style for command output + Output string +} + +// NewRepositoriesCommand returns a new cobra command for 'pizza insights repositories' +func NewRepositoriesCommand() *cobra.Command { + opts := &repositoriesOptions{} + cmd := &cobra.Command{ + Use: "repositories url... [flags]", + Short: "Gather insights about indexed git repositories", + Long: "Gather insights about indexed git repositories. This command will show info about contributors, pull requests, etc.", + 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) + output, _ := cmd.Flags().GetString(constants.FlagNameOutput) + opts.Output = output + return opts.run(context.TODO()) + }, + } + 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 (opts *repositoriesOptions) run(ctx context.Context) 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)) + insightsChan = make(chan repositoryInsights, len(repositories)) + doneChan = make(chan struct{}) + insights = make(repositoryInsightsSlice, 0, len(repositories)) + allErrors error + ) + go func() { + for url := range repositories { + repoURL := url + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + allData, err := findAllRepositoryInsights(ctx, opts, repoURL) + if err != nil { + errorChan <- err + return + } + if allData == nil { + return + } + insightsChan <- *allData + }() + } + waitGroup.Wait() + close(doneChan) + }() + for { + select { + case err = <-errorChan: + allErrors = errors.Join(allErrors, err) + case data := <-insightsChan: + insights = append(insights, data) + case <-doneChan: + if allErrors != nil { + return allErrors + } + output, err := insights.BuildOutput(opts.Output) + if err != nil { + return err + } + fmt.Println(output) + return nil + } + } +} + +type repositoryInsights struct { + RepoURL string `json:"repo_url" yaml:"repo_url"` + RepoID int `json:"-" yaml:"-"` + AllPullRequests int `json:"all_pull_requests" yaml:"all_pull_requests"` + AcceptedPullRequests int `json:"accepted_pull_requests" yaml:"accepted_pull_requests"` + SpamPullRequests int `json:"spam_pull_requests" yaml:"spam_pull_requests"` + Contributors []string `json:"contributors" yaml:"contributors"` +} + +type repositoryInsightsSlice []repositoryInsights + +func (ris repositoryInsightsSlice) BuildOutput(format string) (string, error) { + switch format { + case constants.OutputTable: + return ris.OutputTable() + case constants.OutputJSON: + return utils.OutputJSON(ris) + case constants.OutputYAML: + return utils.OutputYAML(ris) + default: + return "", fmt.Errorf("unknown output format %s", format) + } +} + +func (ris repositoryInsightsSlice) OutputTable() (string, error) { + tables := make([]string, 0, len(ris)) + for i := range ris { + rows := []bubblesTable.Row{ + { + "All pull requests", + strconv.Itoa(ris[i].AllPullRequests), + }, + { + "Accepted pull requests", + strconv.Itoa(ris[i].AcceptedPullRequests), + }, + { + "Spam pull requests", + strconv.Itoa(ris[i].SpamPullRequests), + }, + { + "Contributors", + strconv.Itoa(len(ris[i].Contributors)), + }, + } + columns := []bubblesTable.Column{ + { + Title: "Repository URL", + Width: utils.GetMaxTableRowWidth(rows), + }, + { + Title: ris[i].RepoURL, + Width: len(ris[i].RepoURL), + }, + } + tables = append(tables, utils.OutputTable(rows, columns)) + } + separator := fmt.Sprintf("\n%s\n", strings.Repeat("―", 3)) + return strings.Join(tables, separator), nil +} + +func findAllRepositoryInsights(ctx context.Context, opts *repositoriesOptions, repoURL string) (*repositoryInsights, error) { + repo, err := findRepositoryByOwnerAndRepoName(ctx, opts.APIClient, repoURL) + if err != nil { + return nil, fmt.Errorf("could not get repository insights for repository %s: %w", repoURL, err) + } + if repo == nil { + return nil, nil + } + repoInsights := &repositoryInsights{ + RepoID: int(repo.Id), + RepoURL: repo.SvnUrl, + } + var ( + waitGroup = new(sync.WaitGroup) + errorChan = make(chan error, 4) + ) + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + response, err := getPullRequestInsights(ctx, opts.APIClient, repo.Id, opts.Period) + if err != nil { + errorChan <- err + return + } + repoInsights.AllPullRequests = int(response.AllPrs) + repoInsights.AcceptedPullRequests = int(response.AcceptedPrs) + repoInsights.SpamPullRequests = int(response.SpamPrs) + }() + waitGroup.Add(1) + go func() { + defer waitGroup.Done() + response, err := searchAllPullRequestContributors(ctx, opts.APIClient, repo.Id, opts.Period) + if err != nil { + errorChan <- err + return + } + var contributors []string + for _, contributor := range response { + contributors = append(contributors, contributor.AuthorLogin) + } + repoInsights.Contributors = contributors + }() + waitGroup.Wait() + close(errorChan) + if len(errorChan) > 0 { + var allErrors error + for err = range errorChan { + allErrors = errors.Join(allErrors, err) + } + return nil, allErrors + } + return repoInsights, nil +} + +func getPullRequestInsights(ctx context.Context, apiClient *client.APIClient, repoID, period int32) (*client.DbPRInsight, error) { + data, _, err := apiClient.PullRequestsServiceAPI. + GetPullRequestInsights(ctx). + RepoIds(strconv.Itoa(int(repoID))). + Execute() + if err != nil { + return nil, fmt.Errorf("error while calling 'PullRequestsServiceAPI.GetPullRequestInsights' with repository %d': %w", repoID, err) + } + index := slices.IndexFunc(data, func(insight client.DbPRInsight) bool { + return insight.Interval == period + }) + if index == -1 { + return nil, fmt.Errorf("could not find pull request insights for repository %d with interval %d", repoID, period) + } + return &data[index], nil +} + +func searchAllPullRequestContributors(ctx context.Context, apiClient *client.APIClient, repoID, period int32) ([]client.DbPullRequestContributor, error) { + var ( + allData []client.DbPullRequestContributor + page int32 = 1 + ) + for { + data, _, err := apiClient.ContributorsServiceAPI. + SearchAllPullRequestContributors(ctx). + RepoIds(strconv.Itoa(int(repoID))). + Range_(period). + Limit(50). + Page(page). + Execute() + if err != nil { + return nil, fmt.Errorf("error while calling 'ContributorsServiceAPI.SearchAllPullRequestContributors' with repository %d': %w", repoID, err) + } + allData = append(allData, data.Data...) + if !data.Meta.HasNextPage { + break + } + page++ + } + return allData, nil +} diff --git a/cmd/repo-query/repo-query.go b/cmd/repo-query/repo-query.go index b78e98b..ff27edd 100644 --- a/cmd/repo-query/repo-query.go +++ b/cmd/repo-query/repo-query.go @@ -13,17 +13,20 @@ 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 // URL is the git repo URL that will be indexed URL string + // telemetry for capturing CLI events + telemetry *utils.PosthogCliClient + branch string } @@ -49,23 +52,29 @@ func NewRepoQueryCommand() *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, args []string) error { - 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(endpointURL) + opts.URL = args[0] - opts.APIClient = api.NewClient(endpoint) + disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) + if !disableTelem { + opts.telemetry = utils.NewPosthogCliClient() + defer opts.telemetry.Done() + + opts.telemetry.CaptureRepoQuery(opts.URL) + } - opts.URL = args[0] return run(opts) }, } @@ -85,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 4ad6e77..cf21f0e 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -4,12 +4,14 @@ 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/pkg/api" + "github.com/open-sauced/pizza-cli/cmd/show" + "github.com/open-sauced/pizza-cli/cmd/version" + "github.com/open-sauced/pizza-cli/pkg/constants" + "github.com/spf13/cobra" ) // NewRootCommand bootstraps a new root cobra command for the pizza CLI @@ -17,16 +19,31 @@ func NewRootCommand() (*cobra.Command, error) { cmd := &cobra.Command{ Use: "pizza [flags]", Short: "OpenSauced CLI", - Long: `A command line utility for insights, metrics, and all things OpenSauced`, + 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().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.PersistentFlags().StringP(constants.FlagNameOutput, "o", constants.OutputTable, "The formatting style for command output") cmd.AddCommand(bake.NewBakeCommand()) cmd.AddCommand(repoquery.NewRepoQueryCommand()) cmd.AddCommand(auth.NewLoginCommand()) + cmd.AddCommand(insights.NewInsightsCommand()) + cmd.AddCommand(version.NewVersionCommand()) + cmd.AddCommand(show.NewShowCommand()) return cmd, nil } diff --git a/cmd/show/constants.go b/cmd/show/constants.go new file mode 100644 index 0000000..9c68085 --- /dev/null +++ b/cmd/show/constants.go @@ -0,0 +1,63 @@ +package show + +import ( + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// WindowSize stores the size of the terminal +var WindowSize tea.WindowSizeMsg + +// Keymaps +var OpenPR = key.NewBinding(key.WithKeys("O"), key.WithHelp("O", "open pr")) +var BackToDashboard = key.NewBinding(key.WithKeys("B"), key.WithHelp("B", "back")) +var ToggleHelpMenu = key.NewBinding(key.WithKeys("H"), key.WithHelp("H", "toggle help")) + +// STYLES +// Viewport: The viewport of the tui (my:2, mx:2) +var Viewport = lipgloss.NewStyle().Margin(1, 2) + +// Container: container styling (width: 80, py: 0, px: 5) +var Container = lipgloss.NewStyle().Width(80).Padding(0, 5) + +// WidgetContainer: container for tables, and graphs (py:2, px:2) +var WidgetContainer = lipgloss.NewStyle().Padding(2, 2) + +// SquareBorder: Style to draw a border around a section +var SquareBorder = lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(Color) + +// TextContainer: container for text +var TextContainer = lipgloss.NewStyle().Padding(1, 1) + +// TableTitle: The style for table titles (width:25, align-horizontal:center, bold:true) +var TableTitle = lipgloss.NewStyle().Width(25).AlignHorizontal(lipgloss.Center).Bold(true) + +// Color: the color palette (Light: #000000, Dark: #FF4500) +var Color = lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FF4500"} + +// ActiveStyle: table when selected (border:normal, border-foreground:#FF4500) +var ActiveStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#FF4500")) + +// InactiveStyle: table when not selected (border:normal, border-foreground:#FFFFFF) +var InactiveStyle = lipgloss.NewStyle(). + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(lipgloss.Color("#FFFFFF")) + +// ItemStyle: style applied to items in a list.Model +var ItemStyle = lipgloss.NewStyle().PaddingLeft(4) + +// SelectedItemStyle: style applied when the item is selected in a list.Model +var SelectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(Color) + +// ListItemTitle: style for the list.Model title +var ListItemTitleStyle = lipgloss.NewStyle().MarginLeft(2) + +// PaginationStyle: style for pagination of list.Model +var PaginationStyle = list.DefaultStyles().PaginationStyle.PaddingLeft(4) + +// HelpStyle: style for help menu +var HelpStyle = list.DefaultStyles().HelpStyle.PaddingLeft(4).PaddingBottom(1) diff --git a/cmd/show/contributors.go b/cmd/show/contributors.go new file mode 100644 index 0000000..e021b2f --- /dev/null +++ b/cmd/show/contributors.go @@ -0,0 +1,268 @@ +package show + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "sync" + + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/cli/browser" + client "github.com/open-sauced/go-api/client" +) + +// prItem: type for pull request to satisfy the list.Item interface +type prItem client.DbPullRequest + +func (i prItem) FilterValue() string { return i.Title } +func (i prItem) GetRepoName() string { + if i.FullName != nil { + return *i.FullName + } + return "" +} + +type itemDelegate struct{} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 1 } +func (d itemDelegate) Update(_ tea.Msg, _ *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(prItem) + if !ok { + return + } + + prTitle := i.Title + if len(prTitle) >= 60 { + prTitle = fmt.Sprintf("%s...", prTitle[:60]) + } + + str := fmt.Sprintf("#%d %s\n%s\n(%s)", i.Number, i.GetRepoName(), prTitle, i.State) + + fn := ItemStyle.Render + if index == m.Index() { + fn = func(s ...string) string { + return SelectedItemStyle.Render("πŸ• " + strings.Join(s, " ")) + } + } + + fmt.Fprint(w, fn(str)) +} + +// ContributorModel holds all the information related to a contributor +type ContributorModel struct { + username string + userInfo *client.DbUser + prList list.Model + prVelocity float64 + APIClient *client.APIClient + serverContext context.Context +} + +type ( + // BackMsg: message to signal main model that we are back to dashboard when backspace is pressed + BackMsg struct{} + + // ContributorErrMsg: message to signal that an error occurred when fetching contributor information + ContributorErrMsg struct { + name string + err error + } +) + +// InitContributor: initializes the contributorModel +func InitContributor(opts *Options) (tea.Model, error) { + var model ContributorModel + model.APIClient = opts.APIClient + model.serverContext = opts.ServerContext + + return model, nil +} + +func (m ContributorModel) Init() tea.Cmd { return nil } + +func (m ContributorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + WindowSize = msg + case SelectMsg: + m.username = msg.contributorName + model, err := m.fetchUser() + if err != nil { + return m, func() tea.Msg { return ContributorErrMsg{name: msg.contributorName, err: err} } + } + return model, func() tea.Msg { return SuccessMsg{} } + case tea.KeyMsg: + switch msg.String() { + case "B": + if !m.prList.SettingFilter() { + return m, func() tea.Msg { return BackMsg{} } + } + case "H": + if !m.prList.SettingFilter() { + m.prList.SetShowHelp(!m.prList.ShowHelp()) + return m, nil + } + case "O": + if !m.prList.SettingFilter() { + pr, ok := m.prList.SelectedItem().(prItem) + if ok { + err := browser.OpenURL(fmt.Sprintf("https://github.com/%s/pull/%d", pr.GetRepoName(), pr.Number)) + if err != nil { + fmt.Println("could not open pull request in browser") + } + } + } + case "q", "ctrl+c", "ctrl+d": + if !m.prList.SettingFilter() { + return m, tea.Quit + } + } + } + m.prList, cmd = m.prList.Update(msg) + return m, cmd +} + +func (m ContributorModel) View() string { + return m.drawContributorView() +} + +// fetchUser: fetches all the user information (general info, and pull requests) +func (m *ContributorModel) fetchUser() (tea.Model, error) { + var ( + wg sync.WaitGroup + errChan = make(chan error, 2) + ) + + wg.Add(1) + go func() { + defer wg.Done() + err := m.fetchContributorInfo(m.username) + if err != nil { + errChan <- err + return + } + }() + + wg.Add(1) + go func() { + defer wg.Done() + err := m.fetchContributorPRs(m.username) + if err != nil { + errChan <- err + return + } + }() + + wg.Wait() + close(errChan) + if len(errChan) > 0 { + var allErrors error + for err := range errChan { + allErrors = errors.Join(allErrors, err) + } + return m, allErrors + } + + return m, nil +} + +// fetchContributorInfo: fetches the contributor info +func (m *ContributorModel) fetchContributorInfo(name string) error { + resp, r, err := m.APIClient.UserServiceAPI.FindOneUserByUserame(m.serverContext, name).Execute() + if err != nil { + return err + } + + if r.StatusCode != 200 { + return fmt.Errorf("HTTP failed: %d", r.StatusCode) + } + + m.userInfo = resp + return nil +} + +// fetchContributorPRs: fetches the contributor pull requests and creates pull request list +func (m *ContributorModel) fetchContributorPRs(name string) error { + resp, r, err := m.APIClient.UserServiceAPI.FindContributorPullRequests(m.serverContext, name).Range_(30).Execute() + if err != nil { + return err + } + + if r.StatusCode != 200 { + return fmt.Errorf("HTTP failed: %d", r.StatusCode) + } + + // create contributor pull request list + var items []list.Item + var mergedPullRequests int + for _, pr := range resp.Data { + if strings.ToLower(pr.State) == "merged" { + mergedPullRequests++ + } + items = append(items, prItem(pr)) + } + + // calculate pr velocity + if len(resp.Data) <= 0 { + m.prVelocity = 0.0 + } else { + m.prVelocity = (float64(mergedPullRequests) / float64(len(resp.Data))) * 100.0 + } + + l := list.New(items, itemDelegate{}, WindowSize.Width, 14) + l.Title = "✨ Latest Pull Requests" + l.Styles.Title = ListItemTitleStyle + l.Styles.HelpStyle = HelpStyle + l.Styles.NoItems = ItemStyle + l.SetShowStatusBar(false) + l.SetStatusBarItemName("pull request", "pull requests") + l.AdditionalShortHelpKeys = func() []key.Binding { + return []key.Binding{ + OpenPR, + BackToDashboard, + ToggleHelpMenu, + } + } + + m.prList = l + return nil +} + +// drawContributorView: view of the contributor model +func (m *ContributorModel) drawContributorView() string { + contributorInfo := m.drawContributorInfo() + + contributorView := lipgloss.JoinVertical(lipgloss.Left, lipgloss.NewStyle().PaddingLeft(2).Render(contributorInfo), + WidgetContainer.Render(m.prList.View())) + + _, h := lipgloss.Size(contributorView) + if WindowSize.Height < h { + contributorView = lipgloss.JoinHorizontal(lipgloss.Center, contributorInfo, m.prList.View()) + } + + return contributorView +} + +// drawContributorInfo: view of the contributor info (open issues, pr velocity, pr count, maintainer) +func (m *ContributorModel) drawContributorInfo() string { + userOpenIssues := fmt.Sprintf("πŸ“„ Issues: %d", m.userInfo.OpenIssues) + isUserMaintainer := fmt.Sprintf("πŸ”¨ Maintainer: %t", m.userInfo.GetIsMaintainer()) + prVelocity := fmt.Sprintf("πŸ”₯ PR Velocity (30d): %dd - %.0f%% merged", m.userInfo.RecentPullRequestVelocityCount, m.prVelocity) + prCount := fmt.Sprintf("πŸš€ PR Count (30d): %d", m.userInfo.RecentPullRequestsCount) + + prStats := lipgloss.JoinVertical(lipgloss.Left, TextContainer.Render(prVelocity), TextContainer.Render(prCount)) + issuesAndMaintainer := lipgloss.JoinVertical(lipgloss.Center, TextContainer.Render(userOpenIssues), TextContainer.Render(isUserMaintainer)) + + contributorInfo := lipgloss.JoinHorizontal(lipgloss.Center, prStats, issuesAndMaintainer) + contributorView := lipgloss.JoinVertical(lipgloss.Center, m.userInfo.Login, contributorInfo) + + return SquareBorder.Render(contributorView) +} diff --git a/cmd/show/dashboard.go b/cmd/show/dashboard.go new file mode 100644 index 0000000..14efb55 --- /dev/null +++ b/cmd/show/dashboard.go @@ -0,0 +1,328 @@ +package show + +import ( + "context" + "errors" + "fmt" + "strconv" + "sync" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + client "github.com/open-sauced/go-api/client" +) + +const ( + newContributorsView = iota + alumniContributorsView +) + +// DashboardModel holds all the information related to the repository queried (issues, stars, new contributors, alumni contributors) +type DashboardModel struct { + newContributorsTable table.Model + alumniContributorsTable table.Model + RepositoryInfo *client.DbRepo + contributorErr string + tableView int + queryOptions [3]int + APIClient *client.APIClient + serverContext context.Context +} + +// SelectMsg: message to signal the main model that we want to go to the contributor model when 'enter' is pressed +type SelectMsg struct { + contributorName string +} + +// FetchRepoInfo: initializes the dashboard model +func InitDashboard(opts *Options) (tea.Model, error) { + var model DashboardModel + err := validateShowQuery(opts) + if err != nil { + return model, err + } + + resp, r, err := opts.APIClient.RepositoryServiceAPI.FindOneByOwnerAndRepo(opts.ServerContext, opts.Owner, opts.RepoName).Execute() + if err != nil { + return model, err + } + + if r.StatusCode != 200 { + return model, fmt.Errorf("HTTP status: %d", r.StatusCode) + } + + // configuring the dashboardModel + model.RepositoryInfo = resp + model.queryOptions = [3]int{opts.Page, opts.Limit, opts.Range} + model.APIClient = opts.APIClient + model.serverContext = opts.ServerContext + + // fetching the contributor tables + err = model.FetchAllContributors() + if err != nil { + return model, err + } + + return model, nil +} + +func (m DashboardModel) Init() tea.Cmd { + return nil +} + +func (m DashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + WindowSize = msg + + case ErrMsg: + fmt.Printf("Failed to retrieve contributors table data: %s", msg.err.Error()) + return m, tea.Quit + + case ContributorErrMsg: + m.contributorErr = fmt.Sprintf("🚧 could not fetch %s: %s", msg.name, msg.err.Error()) + default: + m.contributorErr = "" + + case tea.KeyMsg: + switch msg.String() { + case "right", "l": + m.tableView = (m.tableView + 1) % 2 + case "left", "h": + if m.tableView-1 <= 0 { + m.tableView = 0 + } else { + m.tableView-- + } + case "q", "esc", "ctrl+c", "ctrl+d": + return m, tea.Quit + case "enter": + switch m.tableView { + case newContributorsView: + if len(m.newContributorsTable.Rows()) > 0 { + return m, func() tea.Msg { return SelectMsg{contributorName: m.newContributorsTable.SelectedRow()[1]} } + } + case alumniContributorsView: + if len(m.alumniContributorsTable.Rows()) > 0 { + return m, func() tea.Msg { return SelectMsg{contributorName: m.alumniContributorsTable.SelectedRow()[1]} } + } + } + } + + switch m.tableView { + case newContributorsView: + m.newContributorsTable, cmd = m.newContributorsTable.Update(msg) + case alumniContributorsView: + m.alumniContributorsTable, cmd = m.alumniContributorsTable.Update(msg) + } + } + + return m, cmd +} + +func (m DashboardModel) View() string { + return m.drawDashboardView() +} + +// drawTitle: view of PIZZA +func (m *DashboardModel) drawTitle() string { + titleRunes1 := []rune{'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•—', ' ', 'β–ˆ', 'β–ˆ', 'β•—', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•—', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•—', ' ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•—', ' '} + titleRunes2 := []rune{'β–ˆ', 'β–ˆ', 'β•”', '═', '═', 'β–ˆ', 'β–ˆ', 'β•—', 'β–ˆ', 'β–ˆ', 'β•‘', 'β•š', '═', '═', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•”', '╝', 'β•š', '═', '═', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•”', '╝', 'β–ˆ', 'β–ˆ', 'β•”', '═', '═', 'β–ˆ', 'β–ˆ', 'β•—'} + titleRunes3 := []rune{'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•”', '╝', 'β–ˆ', 'β–ˆ', 'β•‘', ' ', ' ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•”', '╝', ' ', ' ', ' ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•”', '╝', ' ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•‘'} + titleRunes4 := []rune{'β–ˆ', 'β–ˆ', 'β•”', '═', '═', '═', '╝', ' ', 'β–ˆ', 'β–ˆ', 'β•‘', ' ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•”', '╝', ' ', ' ', ' ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•”', '╝', ' ', ' ', 'β–ˆ', 'β–ˆ', 'β•”', '═', '═', 'β–ˆ', 'β–ˆ', 'β•‘'} + titleRunes5 := []rune{'β–ˆ', 'β–ˆ', 'β•‘', ' ', ' ', ' ', ' ', ' ', 'β–ˆ', 'β–ˆ', 'β•‘', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•—', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β–ˆ', 'β•—', 'β–ˆ', 'β–ˆ', 'β•‘', ' ', ' ', 'β–ˆ', 'β–ˆ', 'β•‘'} + titleRunes6 := []rune{'β•š', '═', '╝', ' ', ' ', ' ', ' ', ' ', 'β•š', '═', '╝', 'β•š', '═', '═', '═', '═', '═', '═', '╝', 'β•š', '═', '═', '═', '═', '═', '═', '╝', 'β•š', '═', '╝', ' ', ' ', 'β•š', '═', '╝'} + + title1 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes1)) + title2 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes2)) + title3 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes3)) + title4 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes4)) + title5 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes5)) + title6 := lipgloss.JoinHorizontal(lipgloss.Left, string(titleRunes6)) + title := lipgloss.JoinVertical(lipgloss.Center, title1, title2, title3, title4, title5, title6) + titleView := lipgloss.NewStyle().Foreground(Color).Render(title) + + return titleView +} + +// drawRepositoryInfo: view of the repository info (name, stars, size, and issues) +func (m *DashboardModel) drawRepositoryInfo() string { + repoName := lipgloss.NewStyle().Bold(true).AlignHorizontal(lipgloss.Center).Render(fmt.Sprintf("Repository: %s", m.RepositoryInfo.FullName)) + repoStars := fmt.Sprintf("🌟 stars: %d", m.RepositoryInfo.Stars) + repoSize := fmt.Sprintf("πŸ’Ύ size: %dB", m.RepositoryInfo.Size) + repoIssues := fmt.Sprintf("πŸ“„ issues: %d", m.RepositoryInfo.Issues) + repoForks := fmt.Sprintf("β‘‚ forks: %d", m.RepositoryInfo.Forks) + + issuesAndForks := lipgloss.JoinVertical(lipgloss.Center, TextContainer.Render(repoIssues), TextContainer.Render(repoForks)) + sizeAndStars := lipgloss.JoinVertical(lipgloss.Left, TextContainer.Render(repoSize), TextContainer.Render(repoStars)) + + repoGeneralSection := lipgloss.JoinHorizontal(lipgloss.Center, issuesAndForks, sizeAndStars) + repositoryInfoView := lipgloss.JoinVertical(lipgloss.Center, repoName, repoGeneralSection) + frame := SquareBorder.Render(repositoryInfoView) + + return frame +} + +// drawMetrics: view of metrics includes. +// - new contributors table +// - alumni contributors table +func (m *DashboardModel) drawMetrics() string { + var newContributorsDisplay, alumniContributorsDisplay string + + switch m.tableView { + case newContributorsView: + newContributorsDisplay = lipgloss.JoinVertical(lipgloss.Center, TableTitle.Render("πŸ• New Contributors"), ActiveStyle.Render(m.newContributorsTable.View())) + alumniContributorsDisplay = lipgloss.JoinVertical(lipgloss.Center, TableTitle.Render("🍁 Alumni Contributors"), InactiveStyle.Render(m.alumniContributorsTable.View())) + case alumniContributorsView: + newContributorsDisplay = lipgloss.JoinVertical(lipgloss.Center, TableTitle.Render("πŸ• New Contributors"), InactiveStyle.Render(m.newContributorsTable.View())) + alumniContributorsDisplay = lipgloss.JoinVertical(lipgloss.Center, TableTitle.Render("🍁 Alumni Contributors"), ActiveStyle.Render(m.alumniContributorsTable.View())) + } + + contributorsMetrics := lipgloss.JoinHorizontal(lipgloss.Center, WidgetContainer.Render(newContributorsDisplay), WidgetContainer.Render(alumniContributorsDisplay)) + + return contributorsMetrics +} + +// drawDashboardView: this is the main model view (shows repository info and tables) +func (m *DashboardModel) drawDashboardView() string { + if WindowSize.Width == 0 { + return "Loading..." + } + titleView, repoInfoView, metricsView := m.drawTitle(), m.drawRepositoryInfo(), m.drawMetrics() + mainView := lipgloss.JoinVertical(lipgloss.Center, titleView, repoInfoView, metricsView, m.contributorErr) + + _, h := lipgloss.Size(mainView) + if WindowSize.Height < h { + contentLeft := lipgloss.JoinVertical(lipgloss.Center, titleView, repoInfoView) + contentRight := lipgloss.JoinVertical(lipgloss.Center, metricsView, m.contributorErr) + mainView = lipgloss.JoinHorizontal(lipgloss.Center, contentLeft, contentRight) + } + frame := Viewport.Render(mainView) + return frame +} + +// validateShowQuery: validates fields set to query the contributor tables +func validateShowQuery(opts *Options) error { + if opts.Limit < 1 { + return errors.New("--limit flag must be a positive integer value") + } + if opts.Range < 1 { + return errors.New("--range flag must be a positive integer value") + } + + if opts.Page < 1 { + return errors.New("--page flag must be a positive integer value") + } + + return nil +} + +// FetchAllContributors: fetchs and sets all the contributors (new, alumni) +func (m *DashboardModel) FetchAllContributors() error { + var ( + errorChan = make(chan error, 2) + wg sync.WaitGroup + ) + + wg.Add(1) + go func() { + defer wg.Done() + newContributors, err := m.FetchNewContributors() + if err != nil { + errorChan <- err + return + } + m.newContributorsTable = setupContributorsTable(newContributors) + }() + + wg.Add(1) + go func() { + defer wg.Done() + alumniContributors, err := m.FetchAlumniContributors() + if err != nil { + errorChan <- err + return + } + m.alumniContributorsTable = setupContributorsTable(alumniContributors) + }() + + wg.Wait() + close(errorChan) + if len(errorChan) > 0 { + var allErrors error + for err := range errorChan { + allErrors = errors.Join(allErrors, err) + } + return allErrors + } + + return nil +} + +// FetchNewContributors: Returns all the new contributors +func (m *DashboardModel) FetchNewContributors() ([]client.DbPullRequestContributor, error) { + resp, r, err := m.APIClient.ContributorsServiceAPI.NewPullRequestContributors(m.serverContext).Page(int32(m.queryOptions[0])). + Limit(int32(m.queryOptions[1])).RepoIds(strconv.Itoa(int(m.RepositoryInfo.Id))).Execute() + if err != nil { + return nil, err + } + + if r.StatusCode != 200 { + return nil, fmt.Errorf("HTTP status: %d", r.StatusCode) + } + + return resp.Data, nil + +} + +// FetchAlumniContributors: Returns all alumni contributors +func (m *DashboardModel) FetchAlumniContributors() ([]client.DbPullRequestContributor, error) { + resp, r, err := m.APIClient.ContributorsServiceAPI.FindAllChurnPullRequestContributors(m.serverContext). + Page(int32(m.queryOptions[0])).Limit(int32(m.queryOptions[1])). + Range_(int32(m.queryOptions[2])).RepoIds(strconv.Itoa(int(m.RepositoryInfo.Id))).Execute() + if err != nil { + return nil, err + } + + if r.StatusCode != 200 { + return nil, fmt.Errorf("HTTP status: %d", r.StatusCode) + } + + return resp.Data, nil +} + +// setupContributorsTable: sets the contributor table UI +func setupContributorsTable(contributors []client.DbPullRequestContributor) table.Model { + columns := []table.Column{ + {Title: "ID", Width: 5}, + {Title: "Name", Width: 20}, + } + + rows := []table.Row{} + + for i, contributor := range contributors { + rows = append(rows, table.Row{strconv.Itoa(i), contributor.AuthorLogin}) + } + + contributorTable := table.New( + table.WithColumns(columns), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(15), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + Bold(true) + + s.Selected = s.Selected. + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#FF4500")) + + contributorTable.SetStyles(s) + return contributorTable +} diff --git a/cmd/show/show.go b/cmd/show/show.go new file mode 100644 index 0000000..9ab79e0 --- /dev/null +++ b/cmd/show/show.go @@ -0,0 +1,96 @@ +// Package show contains the bootstrapping and tooling for the pizza show +// cobra command +package show + +import ( + "context" + "errors" + + client "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" +) + +// Options are the options for the pizza show command including user +// defined configurations +type Options struct { + // Owner: the owner of the repository + Owner string + + // RepoName: the name of the repository + RepoName string + + // Page is the page to be requested + Page int + + // Limit is the number of records to be retrieved + Limit int + + // Range is the number of days to take into account when retrieving statistics + Range int + + // APIClient is the api client to interface with open sauced api + APIClient *client.APIClient + + ServerContext context.Context +} + +const showLongDesc string = `WARNING: Proof of concept feature. + +The show command accepts the name of a git repository and uses OpenSauced api +to retrieve metrics of the repository to be displayed as a TUI.` + +// NewShowCommand returns a new cobra command for 'pizza show' +func NewShowCommand() *cobra.Command { + opts := &Options{} + + cmd := &cobra.Command{ + Use: "show repository-name [flags]", + Short: "Get visual metrics of a repository", + Long: showLongDesc, + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("must specify the URL of a git repository to analyze") + } + + owner, name, err := utils.GetOwnerAndRepoFromURL(args[0]) + if err != nil { + return err + } + opts.Owner = owner + opts.RepoName = name + + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + var endpoint string + customEndpoint, _ := cmd.Flags().GetString("endpoint") + if customEndpoint != "" { + endpoint = customEndpoint + } + + useBeta, _ := cmd.Flags().GetBool("beta") + if useBeta { + endpoint = constants.EndpointBeta + } + + opts.APIClient = api.NewGoClient(endpoint) + opts.ServerContext = context.TODO() + return run(opts) + }, + } + + cmd.Flags().IntVarP(&opts.Range, "range", "r", 30, "The last N number of days to consider") + cmd.Flags().IntVarP(&opts.Limit, "limit", "l", 10, "The number of records to retrieve") + cmd.Flags().IntVarP(&opts.Page, "page", "p", 1, "The page number to retrieve") + + return cmd +} + +func run(opts *Options) error { + // Load the pizza TUI + err := pizzaTUI(opts) + return err +} diff --git a/cmd/show/tui.go b/cmd/show/tui.go new file mode 100644 index 0000000..141367e --- /dev/null +++ b/cmd/show/tui.go @@ -0,0 +1,95 @@ +package show + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" +) + +const ( + dashboardView = iota + contributorView +) + +type ( + // sessionState serves as the variable to reference when looking for which model the user is in + sessionState int + + // SuccessMsg: message to represent success when fetching + SuccessMsg struct{} + + // ErrMsg: this message represents an error when fetching + ErrMsg struct { + err error + } +) + +// MainModel: the main model is the central state manager of the TUI, decides which model is focused based on certain commands +type MainModel struct { + state sessionState + dashboard tea.Model + contributor tea.Model +} + +// View: the view of the TUI +func (m MainModel) View() string { + switch m.state { + case contributorView: + return m.contributor.View() + default: + return m.dashboard.View() + } +} + +// Init: initial IO before program start +func (m MainModel) Init() tea.Cmd { + return nil +} + +// Update: Handle IO and Commands +func (m MainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + switch msg := msg.(type) { + case BackMsg: + m.state = dashboardView + case SuccessMsg: + m.state = contributorView + case SelectMsg: + newContributor, newCmd := m.contributor.Update(msg) + m.contributor = newContributor + cmds = append(cmds, newCmd) + } + + switch m.state { + case dashboardView: + newDashboard, newCmd := m.dashboard.Update(msg) + m.dashboard = newDashboard + cmd = newCmd + case contributorView: + newContributor, newCmd := m.contributor.Update(msg) + m.contributor = newContributor + cmd = newCmd + } + cmds = append(cmds, cmd) + return m, tea.Batch(cmds...) +} + +func pizzaTUI(opts *Options) error { + dashboardModel, err := InitDashboard(opts) + if err != nil { + return err + } + + contributorModel, err := InitContributor(opts) + if err != nil { + return err + } + + model := MainModel{dashboard: dashboardModel, contributor: contributorModel} + if _, err := tea.NewProgram(model, tea.WithAltScreen()).Run(); err != nil { + return fmt.Errorf("Error running program: %s", err.Error()) + } + + return nil +} diff --git a/cmd/version/version.go b/cmd/version/version.go new file mode 100644 index 0000000..87871e1 --- /dev/null +++ b/cmd/version/version.go @@ -0,0 +1,18 @@ +package version + +import ( + "fmt" + + "github.com/open-sauced/pizza-cli/pkg/utils" + "github.com/spf13/cobra" +) + +func NewVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Displays the build version of the CLI", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("Version: %s\nSha: %s\n", utils.Version, utils.Sha) + }, + } +} diff --git a/go.mod b/go.mod index 1972ac4..e09d154 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,37 @@ module github.com/open-sauced/pizza-cli -go 1.20 +go 1.21 require ( + github.com/charmbracelet/bubbles v0.16.1 + github.com/charmbracelet/bubbletea v0.24.2 + github.com/charmbracelet/lipgloss v0.8.0 github.com/cli/browser v1.2.0 + github.com/open-sauced/go-api/client v0.0.0-20230925192938-8a8b1fa31f60 + github.com/posthog/posthog-go v0.0.0-20230801140217-d607812dee69 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 - golang.org/x/term v0.9.0 + golang.org/x/term v0.12.0 gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.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 - golang.org/x/sys v0.9.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.2 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sahilm/fuzzy v0.1.0 // indirect + golang.org/x/sync v0.1.0 // indirect + golang.org/x/sys v0.12.0 // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/go.sum b/go.sum index a8adb90..fa4ea40 100644 --- a/go.sum +++ b/go.sum @@ -1,19 +1,74 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +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.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY= +github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg= +github.com/charmbracelet/lipgloss v0.8.0 h1:IS00fk4XAHcf8uZKc3eHeMUTCxUH6NkaTrdyCQk84RU= +github.com/charmbracelet/lipgloss v0.8.0/go.mod h1:p4eYUZZJ/0oXTuCQKFF8mqyKCz0ja6y+7DniDDw5KKU= 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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +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.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/open-sauced/go-api/client v0.0.0-20230925192938-8a8b1fa31f60 h1:JqzkafXPIqfn88GkVuVkN3HSWRvBzYLpn7LyVZq+fSg= +github.com/open-sauced/go-api/client v0.0.0-20230925192938-8a8b1fa31f60/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/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= +github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 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.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/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.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +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= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/npm/package.json b/npm/package.json index 4768435..1440ba3 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "pizza", - "version": "0.0.0", + "version": "1.0.0-beta.7", "description": "A command line utility for insights, metrics, and all things OpenSauced", "repository": "https://github.com/open-sauced/pizza-cli", "license": "MIT", 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..7489e8b --- /dev/null +++ b/pkg/constants/flags.go @@ -0,0 +1,11 @@ +package constants + +const ( + FlagNameBeta = "beta" + FlagNameEndpoint = "endpoint" + FlagNameFile = "file" + FlagNameOutput = "output" + FlagNamePeriod = "period" + FlagNameTelemetry = "disable-telemetry" + FlagNameWait = "wait" +) diff --git a/pkg/constants/output.go b/pkg/constants/output.go new file mode 100644 index 0000000..3cdb360 --- /dev/null +++ b/pkg/constants/output.go @@ -0,0 +1,7 @@ +package constants + +const ( + OutputJSON = "json" + OutputTable = "table" + OutputYAML = "yaml" +) 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..05dbcb2 --- /dev/null +++ b/pkg/utils/github.go @@ -0,0 +1,35 @@ +package utils + +import ( + "fmt" + "net/url" + "strings" +) + +// GetOwnerAndRepoFromURL: extracts the owner and repository name +func GetOwnerAndRepoFromURL(input string) (owner, repo string, err error) { + var repoOwner, repoName string + + // check (https://github.com/owner/repo) format + u, err := url.Parse(input) + if err == nil && u.Host == "github.com" { + path := strings.Trim(u.Path, "/") + parts := strings.Split(path, "/") + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid URL: %s", input) + } + repoOwner = parts[0] + repoName = parts[1] + return repoOwner, repoName, nil + } + + // check (owner/repo) format + parts := strings.Split(input, "/") + if len(parts) != 2 { + return "", "", fmt.Errorf("invalid URL: %s", input) + } + repoOwner = parts[0] + repoName = parts[1] + + return repoOwner, repoName, 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/output.go b/pkg/utils/output.go new file mode 100644 index 0000000..dae1842 --- /dev/null +++ b/pkg/utils/output.go @@ -0,0 +1,52 @@ +package utils + +import ( + "encoding/json" + "strings" + + bubblesTable "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/lipgloss" + "gopkg.in/yaml.v3" +) + +func OutputJSON(entity interface{}) (string, error) { + output, err := json.MarshalIndent(entity, "", " ") + if err != nil { + return "", err + } + return string(output), nil +} + +func OutputYAML(entity interface{}) (string, error) { + output, err := yaml.Marshal(entity) + if err != nil { + return "", err + } + return strings.TrimSuffix(string(output), "\n"), nil +} + +func OutputTable(rows []bubblesTable.Row, columns []bubblesTable.Column) string { + styles := bubblesTable.Styles{ + Cell: lipgloss.NewStyle().PaddingRight(1), + Header: lipgloss.NewStyle().Bold(true).PaddingRight(1), + Selected: lipgloss.NewStyle(), + } + table := bubblesTable.New( + bubblesTable.WithRows(rows), + bubblesTable.WithColumns(columns), + bubblesTable.WithHeight(len(rows)), + bubblesTable.WithStyles(styles), + ) + return table.View() +} + +func GetMaxTableRowWidth(rows []bubblesTable.Row) int { + var maxRowWidth int + for i := range rows { + rowWidth := len(rows[i][0]) + if rowWidth > maxRowWidth { + maxRowWidth = rowWidth + } + } + return maxRowWidth +} diff --git a/pkg/utils/posthog.go b/pkg/utils/posthog.go new file mode 100644 index 0000000..0d506ef --- /dev/null +++ b/pkg/utils/posthog.go @@ -0,0 +1,87 @@ +package utils + +import ( + "github.com/posthog/posthog-go" +) + +var ( + writeOnlyPublicPosthogKey = "dev" + posthogEndpoint = "https://app.posthog.com" +) + +// PosthogCliClient is a wrapper around the posthog-go client and is used as a +// API entrypoint for sending OpenSauced telemetry data for CLI commands +type PosthogCliClient struct { + client posthog.Client +} + +// NewPosthogCliClient returns a PosthogCliClient which can be used to capture +// telemetry events for CLI users +func NewPosthogCliClient() *PosthogCliClient { + client, err := posthog.NewWithConfig( + writeOnlyPublicPosthogKey, + posthog.Config{ + Endpoint: posthogEndpoint, + }, + ) + + if err != nil { + // Should never happen since we aren't setting posthog.Config data that + // would cause its validation to fail + panic(err) + } + + return &PosthogCliClient{ + client: client, + } +} + +// Done should always be called in order to flush the Posthog buffers before +// the CLI exits to ensure all events are accurately captured. +// +//nolint:errcheck +func (p *PosthogCliClient) Done() { + p.client.Close() +} + +// CaptureBake gathers telemetry on git repos that are being baked +// +//nolint:errcheck +func (p *PosthogCliClient) CaptureBake(urls []string) { + p.client.Enqueue(posthog.Capture{ + DistinctId: "pizza-bakers", + Event: "cli_user baked repo", + Properties: posthog.NewProperties().Set("clone_url", urls), + }) +} + +// CaptureLogin gathers telemetry on users who log into OpenSauced via the CLI +// +//nolint:errcheck +func (p *PosthogCliClient) CaptureLogin(username string) { + p.client.Enqueue(posthog.Capture{ + DistinctId: username, + Event: "cli_user logged in", + }) +} + +// CaptureFailedLogin gathers telemetry on failed logins via the CLI +// +//nolint:errcheck +func (p *PosthogCliClient) CaptureFailedLogin() { + p.client.Enqueue(posthog.Capture{ + DistinctId: "login-failures", + Event: "cli_user failed log in", + }) +} + +// CaptureRepoQuery gathers telemetry on users using the repo-query service +// +//nolint:errcheck +func (p *PosthogCliClient) CaptureRepoQuery(url string) { + p.client.Enqueue(posthog.Capture{ + DistinctId: "repo-queriers", + Event: "cli_user used repo-query", + Properties: posthog.NewProperties().Set("github_url", url), + }) +} 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) } diff --git a/pkg/utils/version.go b/pkg/utils/version.go new file mode 100644 index 0000000..2630970 --- /dev/null +++ b/pkg/utils/version.go @@ -0,0 +1,6 @@ +package utils + +var ( + Version = "dev" + Sha = "HEAD" +)