diff --git a/.env b/.env new file mode 100644 index 0000000..e05b138 --- /dev/null +++ b/.env @@ -0,0 +1,3 @@ +# Env vars used in the justfile during buildtime + +POSTHOG_PUBLIC_API_KEY="phc_Y0xz6nK55MEwWjobJsI2P8rsiomZJ6eZLoXehmMy9tt" diff --git a/.github/workflows/pizza.yml b/.github/workflows/pizza.yml new file mode 100644 index 0000000..d4661f2 --- /dev/null +++ b/.github/workflows/pizza.yml @@ -0,0 +1,16 @@ +name: OpenSauced Pizza Action + +on: + schedule: + # Run once a week on Sunday at 00:00 UTC + - cron: "0 0 * * 0" + workflow_dispatch: # Allow manual triggering + +jobs: + pizza-action: + runs-on: ubuntu-latest + steps: + - name: Pizza Action + uses: open-sauced/pizza-action@v2.0.0 + with: + commit-and-pr: "true" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index c415fa9..1ec336e 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -28,7 +28,7 @@ jobs: - name: golangci-lint uses: golangci/golangci-lint-action@v6 with: - version: v1.59.1 + version: v1.60 test: runs-on: ubuntu-latest diff --git a/.golangci.yaml b/.golangci.yaml index 560da71..7d2347f 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,15 +1,23 @@ linters: enable: + - asasalint + - asciicheck + - bidichk + - canonicalheader - errcheck + - gci - goimports + - gocritic + - gosec + - govet - ineffassign - misspell - revive + - perfsprint - staticcheck - unconvert - unused - - govet - - gci + - testifylint linters-settings: gci: @@ -23,3 +31,6 @@ linters-settings: run: timeout: 5m + +# attempts to automatically fix linting errors that are fixable by supported linters +fix: true diff --git a/.sauced.yaml b/.sauced.yaml index 6a8f0b7..4726374 100644 --- a/.sauced.yaml +++ b/.sauced.yaml @@ -12,4 +12,6 @@ attribution: nickytonline: - nick@nickyt.co - nick@opensauced.pizza + zeucapua: + - coding@zeu.dev diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d99ced..82b63eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,81 @@ > All notable changes to this project will be documented in this file +## [1.3.1-beta.2](https://github.com/open-sauced/pizza-cli/compare/v1.3.1-beta.1...v1.3.1-beta.2) (2024-09-06) + + +### 🐛 Bug Fixes + +* use the local directory and home directory as fallback for .sauced.yaml ([#158](https://github.com/open-sauced/pizza-cli/issues/158)) ([af2f361](https://github.com/open-sauced/pizza-cli/commit/af2f3612e26634455602d1840714c5bf15e1e40a)) + +## [1.3.1-beta.1](https://github.com/open-sauced/pizza-cli/compare/v1.3.0...v1.3.1-beta.1) (2024-09-06) + + +### 🐛 Bug Fixes + +* skip interactive steps in generate codeowners with --tty-disable flag ([#159](https://github.com/open-sauced/pizza-cli/issues/159)) ([49f1fd3](https://github.com/open-sauced/pizza-cli/commit/49f1fd3fc4df24b95724feb1918dc80276cd017e)) + +## [1.3.0](https://github.com/open-sauced/pizza-cli/compare/v1.2.1...v1.3.0) (2024-09-06) + + +### 🍕 Features + +* Create a contributor list after generating codeowners ([#141](https://github.com/open-sauced/pizza-cli/issues/141)) ([72c5d58](https://github.com/open-sauced/pizza-cli/commit/72c5d588fcd4fb04f6d39d756a6a26a47d25a4e4)) +* now the documentation for the pizza-cli can be generated via pizza docs ([#143](https://github.com/open-sauced/pizza-cli/issues/143)) ([3f5d27e](https://github.com/open-sauced/pizza-cli/commit/3f5d27e2c52c894a266828e70e7475069e74e8e9)) +* Refactors API client into hand rolled sdk in api/ directory ([#111](https://github.com/open-sauced/pizza-cli/issues/111)) ([e16e889](https://github.com/open-sauced/pizza-cli/commit/e16e8899a4ef69641dc614887d065dc8b70adb35)) +* support fallback attributions when generating codeowners file ([#145](https://github.com/open-sauced/pizza-cli/issues/145)) ([35af4da](https://github.com/open-sauced/pizza-cli/commit/35af4dafc4ed088ba1396ff28e1536723c914a2b)) +* update `CODEOWNERS` copy with command ([#130](https://github.com/open-sauced/pizza-cli/issues/130)) ([a477959](https://github.com/open-sauced/pizza-cli/commit/a477959020cfcbb3dc4707efb1700e17e05e3981)) + + +### 🐛 Bug Fixes + +* Corrects invalid gosec lint error ([#151](https://github.com/open-sauced/pizza-cli/issues/151)) ([f76527f](https://github.com/open-sauced/pizza-cli/commit/f76527f0c61c5720f684416f391fe1395774e1fb)) +* Exhume Posthog functionality ([#147](https://github.com/open-sauced/pizza-cli/issues/147)) ([de091ca](https://github.com/open-sauced/pizza-cli/commit/de091cac7df585eadcfae64d6f851cfc178c74a2)) +* now fallback .sauced.yaml contents get read ([#135](https://github.com/open-sauced/pizza-cli/issues/135)) ([fd658e5](https://github.com/open-sauced/pizza-cli/commit/fd658e5e09051cdf007c3605aa880d68db835afb)) +* NPM cache now looks at package-lock file ([#136](https://github.com/open-sauced/pizza-cli/issues/136)) ([cd4b8da](https://github.com/open-sauced/pizza-cli/commit/cd4b8da75e1a0c0aa3d7e6f76d6b560a4dea941f)) + +## [1.3.0-beta.9](https://github.com/open-sauced/pizza-cli/compare/v1.3.0-beta.8...v1.3.0-beta.9) (2024-09-06) + + +### 🐛 Bug Fixes + +* Corrects invalid gosec lint error ([#151](https://github.com/open-sauced/pizza-cli/issues/151)) ([f76527f](https://github.com/open-sauced/pizza-cli/commit/f76527f0c61c5720f684416f391fe1395774e1fb)) + +## [1.3.0-beta.8](https://github.com/open-sauced/pizza-cli/compare/v1.3.0-beta.7...v1.3.0-beta.8) (2024-09-06) + + +### 🍕 Features + +* now the documentation for the pizza-cli can be generated via pizza docs ([#143](https://github.com/open-sauced/pizza-cli/issues/143)) ([3f5d27e](https://github.com/open-sauced/pizza-cli/commit/3f5d27e2c52c894a266828e70e7475069e74e8e9)) + +## [1.3.0-beta.7](https://github.com/open-sauced/pizza-cli/compare/v1.3.0-beta.6...v1.3.0-beta.7) (2024-09-06) + + +### 🐛 Bug Fixes + +* Exhume Posthog functionality ([#147](https://github.com/open-sauced/pizza-cli/issues/147)) ([de091ca](https://github.com/open-sauced/pizza-cli/commit/de091cac7df585eadcfae64d6f851cfc178c74a2)) + +## [1.3.0-beta.6](https://github.com/open-sauced/pizza-cli/compare/v1.3.0-beta.5...v1.3.0-beta.6) (2024-09-05) + + +### 🍕 Features + +* support fallback attributions when generating codeowners file ([#145](https://github.com/open-sauced/pizza-cli/issues/145)) ([35af4da](https://github.com/open-sauced/pizza-cli/commit/35af4dafc4ed088ba1396ff28e1536723c914a2b)) + +## [1.3.0-beta.5](https://github.com/open-sauced/pizza-cli/compare/v1.3.0-beta.4...v1.3.0-beta.5) (2024-09-05) + + +### 🍕 Features + +* Create a contributor list after generating codeowners ([#141](https://github.com/open-sauced/pizza-cli/issues/141)) ([72c5d58](https://github.com/open-sauced/pizza-cli/commit/72c5d588fcd4fb04f6d39d756a6a26a47d25a4e4)) + +## [1.3.0-beta.4](https://github.com/open-sauced/pizza-cli/compare/v1.3.0-beta.3...v1.3.0-beta.4) (2024-09-05) + + +### 🐛 Bug Fixes + +* now fallback .sauced.yaml contents get read ([#135](https://github.com/open-sauced/pizza-cli/issues/135)) ([fd658e5](https://github.com/open-sauced/pizza-cli/commit/fd658e5e09051cdf007c3605aa880d68db835afb)) + ## [1.3.0-beta.3](https://github.com/open-sauced/pizza-cli/compare/v1.3.0-beta.2...v1.3.0-beta.3) (2024-09-04) diff --git a/Dockerfile b/Dockerfile index 9bcb1ad..783f25e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ ARG BUILDPLATFORM ARG VERSION ARG SHA ARG DATETIME +ARG POSTHOG_PUBLIC_API_KEY # Get the dependencies downloaded WORKDIR /app @@ -17,7 +18,8 @@ COPY . ./ RUN go build -ldflags="-s -w \ -X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}' \ -X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=${SHA}' \ - -X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}'" \ + -X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}' \ + -X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ -o pizza . # Runner layer diff --git a/api/auth/auth.go b/api/auth/auth.go index df8f342..970aa2d 100644 --- a/api/auth/auth.go +++ b/api/auth/auth.go @@ -8,6 +8,7 @@ import ( _ "embed" // global import of embed to enable the use of the "go embed" directive "encoding/base64" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -33,8 +34,8 @@ const ( codeChallengeLength = 87 sessionFileName = "session.json" - prodSupabaseURL = "https://fcqqkxwlntnrtjfbcioz.supabase.co" - prodSupabasePublicKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImZjcXFreHdsbnRucnRqZmJjaW96Iiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTg0MTkyNzQsImV4cCI6MjAxMzk5NTI3NH0.ymWWYdnJC2gsnrJx4lZX2cfSOp-1xVuWFGt1Wr6zwtg" + prodSupabaseURL = "https://ibcwmlhcimymasokhgvn.supabase.co" + prodSupabasePublicKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImliY3dtbGhjaW15bWFzb2toZ3ZuIiwicm9sZSI6ImFub24iLCJpYXQiOjE2OTkyOTU1MzMsImV4cCI6MjAxNDg3MTUzM30.Mr-ucuNDBjy9BC7NJzOBBi0Qz8WYiKI4n0JtWr4_woY" // TODO (jpmcb) - in the future, we'll want to encorporate the ability to // authenticate to our beta auth service as well @@ -64,7 +65,7 @@ func NewAuthenticator() *Authenticator { // local server for handling the login. Once the server has completed and received // the session, the server is shut down and control is returned back to the CLI. func (a *Authenticator) Login() (string, error) { - supabaseAuthURL := fmt.Sprintf("%s/auth/v1/authorize", prodSupabaseURL) + supabaseAuthURL := prodSupabaseURL + "/auth/v1/authorize" // 1. Generate the PKCE codeVerifier, codeChallenge, err := a.generatePkce(codeChallengeLength) @@ -79,8 +80,9 @@ func (a *Authenticator) Login() (string, error) { r.Get("/", a.handleLocalCallback) server := &http.Server{ - Addr: authCallbackAddr, - Handler: r, + Addr: authCallbackAddr, + Handler: r, + ReadHeaderTimeout: time.Second * 5, } go func() { @@ -112,7 +114,7 @@ func (a *Authenticator) Login() (string, error) { a.shutdownServer(server) case <-time.After(60 * time.Second): a.shutdownServer(server) - return "", fmt.Errorf("authentication timeout") + return "", errors.New("authentication timeout") } return a.username, nil @@ -124,7 +126,7 @@ func (a *Authenticator) handleLocalCallback(w http.ResponseWriter, r *http.Reque code := r.URL.Query().Get("code") if code == "" { http.Error(w, "'code' query param not found", http.StatusBadRequest) - a.errChan <- fmt.Errorf("'code' query param not found") + a.errChan <- errors.New("'code' query param not found") return } @@ -171,12 +173,22 @@ func (a *Authenticator) CheckSession() error { // Check if session is expired or about to expire (within 5 minutes) if time.Now().Add(5 * time.Minute).After(time.Unix(session.ExpiresAt, 0)) { - return fmt.Errorf("session expired") + return errors.New("session expired") } return nil } +// GetSessionToken returns the access token for a given session +func (a *Authenticator) GetSessionToken() (string, error) { + session, err := a.readSessionFile() + if err != nil { + return "", fmt.Errorf("failed to read session file: %w", err) + } + + return session.AccessToken, nil +} + // readSessionFile reads a session file and returns the session struct. func (a *Authenticator) readSessionFile() (*session, error) { configDir, err := config.GetConfigDirectory() @@ -223,7 +235,7 @@ func (a *Authenticator) generatePkce(length int) (string, string, error) { // getSession takes an authentication code and a verifier, using the Supabase // auth service, to get a session func (a *Authenticator) getSession(authCode, codeVerifier string) (*session, error) { - url := fmt.Sprintf("%s/auth/v1/token?grant_type=pkce", prodSupabaseURL) + url := prodSupabaseURL + "/auth/v1/token?grant_type=pkce" payload := map[string]string{ "auth_code": authCode, @@ -234,7 +246,7 @@ func (a *Authenticator) getSession(authCode, codeVerifier string) (*session, err req, _ := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload)) req.Header.Set("Content-Type", "application/json;charset=UTF-8") - req.Header.Set("ApiKey", prodSupabasePublicKey) + req.Header.Set("Apikey", prodSupabasePublicKey) res, err := http.DefaultClient.Do(req) if err != nil { diff --git a/api/client.go b/api/client.go index 4429a8e..04bf1de 100644 --- a/api/client.go +++ b/api/client.go @@ -7,6 +7,7 @@ import ( "github.com/open-sauced/pizza-cli/api/services/contributors" "github.com/open-sauced/pizza-cli/api/services/histogram" "github.com/open-sauced/pizza-cli/api/services/repository" + "github.com/open-sauced/pizza-cli/api/services/workspaces" ) // Client is the API client for OpenSauced API @@ -15,6 +16,7 @@ type Client struct { RepositoryService *repository.Service ContributorService *contributors.Service HistogramService *histogram.Service + WorkspacesService *workspaces.Service // The configured http client for making API requests httpClient *http.Client @@ -40,6 +42,7 @@ func NewClient(endpoint string) *Client { client.ContributorService = contributors.NewContributorsService(client.httpClient, client.endpoint) client.RepositoryService = repository.NewRepositoryService(client.httpClient, client.endpoint) client.HistogramService = histogram.NewHistogramService(client.httpClient, client.endpoint) + client.WorkspacesService = workspaces.NewWorkspacesService(client.httpClient, client.endpoint) return &client } diff --git a/api/services/contributors/contributors.go b/api/services/contributors/contributors.go index 1d34d38..0ab6d4e 100644 --- a/api/services/contributors/contributors.go +++ b/api/services/contributors/contributors.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "strconv" "strings" ) @@ -25,7 +26,7 @@ func NewContributorsService(httpClient *http.Client, endpoint string) *Service { // NewPullRequestContributors calls the "v2/contributors/insights/new" API endpoint func (s *Service) NewPullRequestContributors(repos []string, rangeVal int) (*ContribResponse, *http.Response, error) { - baseURL := fmt.Sprintf("%s/v2/contributors/insights/new", s.endpoint) + baseURL := s.endpoint + "/v2/contributors/insights/new" // Create URL with query parameters u, err := url.Parse(baseURL) @@ -34,7 +35,7 @@ func (s *Service) NewPullRequestContributors(repos []string, rangeVal int) (*Con } q := u.Query() - q.Set("range", fmt.Sprintf("%d", rangeVal)) + q.Set("range", strconv.Itoa(rangeVal)) q.Set("repos", strings.Join(repos, ",")) u.RawQuery = q.Encode() @@ -58,7 +59,7 @@ func (s *Service) NewPullRequestContributors(repos []string, rangeVal int) (*Con // RecentPullRequestContributors calls the "v2/contributors/insights/recent" API endpoint func (s *Service) RecentPullRequestContributors(repos []string, rangeVal int) (*ContribResponse, *http.Response, error) { - baseURL := fmt.Sprintf("%s/v2/contributors/insights/recent", s.endpoint) + baseURL := s.endpoint + "/v2/contributors/insights/recent" // Create URL with query parameters u, err := url.Parse(baseURL) @@ -67,7 +68,7 @@ func (s *Service) RecentPullRequestContributors(repos []string, rangeVal int) (* } q := u.Query() - q.Set("range", fmt.Sprintf("%d", rangeVal)) + q.Set("range", strconv.Itoa(rangeVal)) q.Set("repos", strings.Join(repos, ",")) u.RawQuery = q.Encode() @@ -91,7 +92,7 @@ func (s *Service) RecentPullRequestContributors(repos []string, rangeVal int) (* // AlumniPullRequestContributors calls the "v2/contributors/insights/alumni" API endpoint func (s *Service) AlumniPullRequestContributors(repos []string, rangeVal int) (*ContribResponse, *http.Response, error) { - baseURL := fmt.Sprintf("%s/v2/contributors/insights/alumni", s.endpoint) + baseURL := s.endpoint + "/v2/contributors/insights/alumni" // Create URL with query parameters u, err := url.Parse(baseURL) @@ -100,7 +101,7 @@ func (s *Service) AlumniPullRequestContributors(repos []string, rangeVal int) (* } q := u.Query() - q.Set("range", fmt.Sprintf("%d", rangeVal)) + q.Set("range", strconv.Itoa(rangeVal)) q.Set("repos", strings.Join(repos, ",")) u.RawQuery = q.Encode() @@ -124,7 +125,7 @@ func (s *Service) AlumniPullRequestContributors(repos []string, rangeVal int) (* // RepeatPullRequestContributors calls the "v2/contributors/insights/repeat" API endpoint func (s *Service) RepeatPullRequestContributors(repos []string, rangeVal int) (*ContribResponse, *http.Response, error) { - baseURL := fmt.Sprintf("%s/v2/contributors/insights/repeat", s.endpoint) + baseURL := s.endpoint + "/v2/contributors/insights/repeat" // Create URL with query parameters u, err := url.Parse(baseURL) @@ -133,7 +134,7 @@ func (s *Service) RepeatPullRequestContributors(repos []string, rangeVal int) (* } q := u.Query() - q.Set("range", fmt.Sprintf("%d", rangeVal)) + q.Set("range", strconv.Itoa(rangeVal)) q.Set("repos", strings.Join(repos, ",")) u.RawQuery = q.Encode() @@ -157,7 +158,7 @@ func (s *Service) RepeatPullRequestContributors(repos []string, rangeVal int) (* // SearchPullRequestContributors calls the "v2/contributors/search" func (s *Service) SearchPullRequestContributors(repos []string, rangeVal int) (*ContribResponse, *http.Response, error) { - baseURL := fmt.Sprintf("%s/v2/contributors/search", s.endpoint) + baseURL := s.endpoint + "/v2/contributors/search" // Create URL with query parameters u, err := url.Parse(baseURL) @@ -166,7 +167,7 @@ func (s *Service) SearchPullRequestContributors(repos []string, rangeVal int) (* } q := u.Query() - q.Set("range", fmt.Sprintf("%d", rangeVal)) + q.Set("range", strconv.Itoa(rangeVal)) q.Set("repos", strings.Join(repos, ",")) u.RawQuery = q.Encode() diff --git a/api/services/contributors/contributors_test.go b/api/services/contributors/contributors_test.go index c26af8d..6d6cd8c 100644 --- a/api/services/contributors/contributors_test.go +++ b/api/services/contributors/contributors_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/open-sauced/pizza-cli/api/mock" "github.com/open-sauced/pizza-cli/api/services" @@ -53,12 +54,12 @@ func TestNewPullrequestContributors(t *testing.T) { newContribs, resp, err := service.NewPullRequestContributors([]string{"testowner/testrepo"}, 30) - assert.NoError(t, err) + require.NoError(t, err) assert.NotNil(t, newContribs) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, newContribs.Data[0].AuthorLogin, "contributor1") - assert.Equal(t, newContribs.Data[1].AuthorLogin, "contributor2") + assert.Equal(t, "contributor1", newContribs.Data[0].AuthorLogin) + assert.Equal(t, "contributor2", newContribs.Data[1].AuthorLogin) // Check the meta information assert.Equal(t, 1, newContribs.Meta.Page) @@ -109,12 +110,12 @@ func TestRecentPullRequestContributors(t *testing.T) { recentContribs, resp, err := service.RecentPullRequestContributors([]string{"testowner/testrepo"}, 30) - assert.NoError(t, err) + require.NoError(t, err) assert.NotNil(t, recentContribs) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, recentContribs.Data[0].AuthorLogin, "contributor1") - assert.Equal(t, recentContribs.Data[1].AuthorLogin, "contributor2") + assert.Equal(t, "contributor1", recentContribs.Data[0].AuthorLogin) + assert.Equal(t, "contributor2", recentContribs.Data[1].AuthorLogin) // Check the meta information assert.Equal(t, 1, recentContribs.Meta.Page) @@ -165,12 +166,12 @@ func TestAlumniPullRequestContributors(t *testing.T) { alumniContribs, resp, err := service.AlumniPullRequestContributors([]string{"testowner/testrepo"}, 30) - assert.NoError(t, err) + require.NoError(t, err) assert.NotNil(t, alumniContribs) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, alumniContribs.Data[0].AuthorLogin, "contributor1") - assert.Equal(t, alumniContribs.Data[1].AuthorLogin, "contributor2") + assert.Equal(t, "contributor1", alumniContribs.Data[0].AuthorLogin) + assert.Equal(t, "contributor2", alumniContribs.Data[1].AuthorLogin) // Check the meta information assert.Equal(t, 1, alumniContribs.Meta.Page) @@ -221,12 +222,12 @@ func TestRepeatPullRequestContributors(t *testing.T) { repeatContribs, resp, err := service.RepeatPullRequestContributors([]string{"testowner/testrepo"}, 30) - assert.NoError(t, err) + require.NoError(t, err) assert.NotNil(t, repeatContribs) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, repeatContribs.Data[0].AuthorLogin, "contributor1") - assert.Equal(t, repeatContribs.Data[1].AuthorLogin, "contributor2") + assert.Equal(t, "contributor1", repeatContribs.Data[0].AuthorLogin) + assert.Equal(t, "contributor2", repeatContribs.Data[1].AuthorLogin) // Check the meta information assert.Equal(t, 1, repeatContribs.Meta.Page) @@ -277,12 +278,12 @@ func TestSearchPullRequestContributors(t *testing.T) { repeatContribs, resp, err := service.SearchPullRequestContributors([]string{"testowner/testrepo"}, 30) - assert.NoError(t, err) + require.NoError(t, err) assert.NotNil(t, repeatContribs) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, repeatContribs.Data[0].AuthorLogin, "contributor1") - assert.Equal(t, repeatContribs.Data[1].AuthorLogin, "contributor2") + assert.Equal(t, "contributor1", repeatContribs.Data[0].AuthorLogin) + assert.Equal(t, "contributor2", repeatContribs.Data[1].AuthorLogin) // Check the meta information assert.Equal(t, 1, repeatContribs.Meta.Page) diff --git a/api/services/histogram/histogram.go b/api/services/histogram/histogram.go index 7dd0f18..b930f89 100644 --- a/api/services/histogram/histogram.go +++ b/api/services/histogram/histogram.go @@ -5,6 +5,7 @@ import ( "fmt" "net/http" "net/url" + "strconv" ) // Service is used to access the API "v2/histogram" endpoints and services @@ -23,7 +24,7 @@ func NewHistogramService(httpClient *http.Client, endpoint string) *Service { // PrsHistogram calls the "v2/histogram/pull-requests" endpoints func (s *Service) PrsHistogram(repo string, rangeVal int) ([]PrHistogramData, *http.Response, error) { - baseURL := fmt.Sprintf("%s/v2/histogram/pull-requests", s.endpoint) + baseURL := s.endpoint + "/v2/histogram/pull-requests" // Create URL with query parameters u, err := url.Parse(baseURL) @@ -32,7 +33,7 @@ func (s *Service) PrsHistogram(repo string, rangeVal int) ([]PrHistogramData, *h } q := u.Query() - q.Set("range", fmt.Sprintf("%d", rangeVal)) + q.Set("range", strconv.Itoa(rangeVal)) q.Set("repo", repo) u.RawQuery = q.Encode() diff --git a/api/services/histogram/histogram_test.go b/api/services/histogram/histogram_test.go index 85446f8..a630565 100644 --- a/api/services/histogram/histogram_test.go +++ b/api/services/histogram/histogram_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/open-sauced/pizza-cli/api/mock" ) @@ -42,10 +43,10 @@ func TestPrsHistogram(t *testing.T) { prs, resp, err := service.PrsHistogram("testowner/testrepo", 30) - assert.NoError(t, err) + require.NoError(t, err) assert.NotNil(t, prs) assert.Equal(t, http.StatusOK, resp.StatusCode) - assert.Equal(t, len(prs), 2) - assert.Equal(t, prs[0].PrCount, 1) - assert.Equal(t, prs[1].PrCount, 2) + assert.Len(t, prs, 2) + assert.Equal(t, 1, prs[0].PrCount) + assert.Equal(t, 2, prs[1].PrCount) } diff --git a/api/services/repository/repository.go b/api/services/repository/repository.go index 56b2f73..eddd830 100644 --- a/api/services/repository/repository.go +++ b/api/services/repository/repository.go @@ -6,6 +6,7 @@ import ( "io" "net/http" "net/url" + "strconv" ) // Service is used to access the "v2/repos" endpoints and services @@ -55,7 +56,7 @@ func (rs *Service) FindContributorsByOwnerAndRepo(owner string, repo string, ran } q := u.Query() - q.Set("range", fmt.Sprintf("%d", rangeVal)) + q.Set("range", strconv.Itoa(rangeVal)) u.RawQuery = q.Encode() resp, err := rs.httpClient.Get(u.String()) diff --git a/api/services/repository/repository_test.go b/api/services/repository/repository_test.go index 64e4a70..3d85cfd 100644 --- a/api/services/repository/repository_test.go +++ b/api/services/repository/repository_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/open-sauced/pizza-cli/api/mock" "github.com/open-sauced/pizza-cli/api/services" @@ -39,7 +40,7 @@ func TestFindOneByOwnerAndRepo(t *testing.T) { repo, resp, err := service.FindOneByOwnerAndRepo("testowner", "testrepo") - assert.NoError(t, err) + require.NoError(t, err) assert.NotNil(t, repo) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Equal(t, 1, repo.ID) @@ -87,7 +88,7 @@ func TestFindContributorsByOwnerAndRepo(t *testing.T) { contributors, resp, err := service.FindContributorsByOwnerAndRepo("testowner", "testrepo", 30) - assert.NoError(t, err) + require.NoError(t, err) assert.NotNil(t, contributors) assert.Equal(t, http.StatusOK, resp.StatusCode) assert.Len(t, contributors.Data, 2) diff --git a/api/services/workspaces/spec.go b/api/services/workspaces/spec.go new file mode 100644 index 0000000..4044d41 --- /dev/null +++ b/api/services/workspaces/spec.go @@ -0,0 +1,46 @@ +package workspaces + +import ( + "time" + + "github.com/open-sauced/pizza-cli/api/services" +) + +type DbWorkspace struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at"` + Name string `json:"name"` + Description string `json:"description"` + IsPublic bool `json:"is_public"` + PayeeUserID *int `json:"payee_user_id"` + Members []DbWorkspaceMember `json:"members"` +} + +type DbWorkspaceMember struct { + ID string `json:"id"` + UserID int `json:"user_id"` + WorkspaceID string `json:"workspace_id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at"` + Role string `json:"role"` +} + +type DbWorkspacesResponse struct { + Data []DbWorkspace `json:"data"` + Meta services.MetaData `json:"meta"` +} + +type CreateWorkspaceRequestRepoInfo struct { + FullName string `json:"full_name"` +} + +type CreateWorkspaceRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Members []string `json:"members"` + Repos []CreateWorkspaceRequestRepoInfo `json:"repos"` + Contributors []string `json:"contributors"` +} diff --git a/api/services/workspaces/userlists/spec.go b/api/services/workspaces/userlists/spec.go new file mode 100644 index 0000000..0fb0531 --- /dev/null +++ b/api/services/workspaces/userlists/spec.go @@ -0,0 +1,48 @@ +package userlists + +import ( + "time" + + "github.com/open-sauced/pizza-cli/api/services" +) + +type DbUserListContributor struct { + ID string `json:"id"` + UserID int `json:"user_id"` + ListID string `json:"list_id"` + Username string `json:"username"` + CreatedAt time.Time `json:"created_at"` +} + +type DbUserList struct { + ID string `json:"id"` + UserID int `json:"user_id"` + Name string `json:"name"` + IsPublic bool `json:"is_public"` + IsFeatured bool `json:"is_featured"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at"` + Contributors []DbUserListContributor `json:"contributors"` +} + +type GetUserListsResponse struct { + Data []DbUserList `json:"data"` + Meta services.MetaData `json:"meta"` +} + +type CreatePatchUserListRequest struct { + Name string `json:"name"` + IsPublic bool `json:"is_public"` + Contributors []CreateUserListRequestContributor `json:"contributors"` +} + +type CreateUserListRequestContributor struct { + Login string `json:"login"` +} + +type CreateUserListResponse struct { + ID string `json:"id"` + UserListID string `json:"user_list_id"` + WorkspaceID string `json:"workspace_id"` +} diff --git a/api/services/workspaces/userlists/userlists.go b/api/services/workspaces/userlists/userlists.go new file mode 100644 index 0000000..7620ce1 --- /dev/null +++ b/api/services/workspaces/userlists/userlists.go @@ -0,0 +1,192 @@ +package userlists + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" +) + +// Service is used to access the "v2/workspaces/:workspaceId/userLists" +// endpoints and services +type Service struct { + httpClient *http.Client + endpoint string +} + +// NewService returns a new UserListsService +func NewService(httpClient *http.Client, endpoint string) *Service { + return &Service{ + httpClient: httpClient, + endpoint: endpoint, + } +} + +// GetUserLists calls the "GET v2/workspaces/:workspaceId/userLists" endpoint +// for the authenticated user +func (s *Service) GetUserLists(token string, workspaceID string, page, limit int) (*GetUserListsResponse, *http.Response, error) { + baseURL := s.endpoint + "/v2/workspaces/" + workspaceID + "/userLists" + + // Create URL with query parameters + u, err := url.Parse(baseURL) + if err != nil { + return nil, nil, fmt.Errorf("error parsing URL: %v", err) + } + + q := u.Query() + q.Set("page", strconv.Itoa(page)) + q.Set("limit", strconv.Itoa(limit)) + u.RawQuery = q.Encode() + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) + } + + var userListsResp GetUserListsResponse + if err := json.NewDecoder(resp.Body).Decode(&userListsResp); err != nil { + return nil, resp, fmt.Errorf("error decoding response: %w", err) + } + + return &userListsResp, resp, nil +} + +// GetUserList calls the "GET v2/workspaces/:workspaceId/userLists" endpoint +// for the authenticated user +func (s *Service) GetUserList(token string, workspaceID string, userlistID string) (*DbUserList, *http.Response, error) { + url := s.endpoint + "/v2/workspaces/" + workspaceID + "/userLists/" + userlistID + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) + } + + var userList DbUserList + if err := json.NewDecoder(resp.Body).Decode(&userList); err != nil { + return nil, resp, fmt.Errorf("error decoding response: %w", err) + } + + return &userList, resp, nil +} + +// CreateUserListForUser calls the "POST v2/workspaces/:workspaceId/userLists" endpoint +// for the authenticated user +func (s *Service) CreateUserListForUser(token string, workspaceID string, name string, logins []string) (*CreateUserListResponse, *http.Response, error) { + url := s.endpoint + "/v2/workspaces/" + workspaceID + "/userLists" + + loginReqs := []CreateUserListRequestContributor{} + for _, login := range logins { + loginReqs = append(loginReqs, CreateUserListRequestContributor{Login: login}) + } + + req := CreatePatchUserListRequest{ + Name: name, + IsPublic: false, + Contributors: loginReqs, + } + + payload, err := json.Marshal(req) + if err != nil { + return nil, nil, fmt.Errorf("error marshaling request: %w", err) + } + + httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) + if err != nil { + return nil, nil, fmt.Errorf("error creating request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+token) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + resp, err := s.httpClient.Do(httpReq) + if err != nil { + return nil, resp, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) + } + + var createdUserList CreateUserListResponse + if err := json.NewDecoder(resp.Body).Decode(&createdUserList); err != nil { + return nil, resp, fmt.Errorf("error decoding response: %w", err) + } + + return &createdUserList, resp, nil +} + +// CreateUserListForUser calls the "PATCH v2/lists/:listId" endpoint +// for the authenticated user +func (s *Service) PatchUserListForUser(token string, workspaceID string, userlistID string, name string, logins []string) (*DbUserList, *http.Response, error) { + url := s.endpoint + "/v2/workspaces/" + workspaceID + "/userLists/" + userlistID + + loginReqs := []CreateUserListRequestContributor{} + for _, login := range logins { + loginReqs = append(loginReqs, CreateUserListRequestContributor{Login: login}) + } + + req := CreatePatchUserListRequest{ + Name: name, + IsPublic: false, + Contributors: loginReqs, + } + + payload, err := json.Marshal(req) + if err != nil { + return nil, nil, fmt.Errorf("error marshaling request: %w", err) + } + + httpReq, err := http.NewRequest("PATCH", url, bytes.NewBuffer(payload)) + if err != nil { + return nil, nil, fmt.Errorf("error creating request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+token) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + resp, err := s.httpClient.Do(httpReq) + if err != nil { + return nil, resp, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) + } + + var createdUserList DbUserList + if err := json.NewDecoder(resp.Body).Decode(&createdUserList); err != nil { + return nil, resp, fmt.Errorf("error decoding response: %w", err) + } + + return &createdUserList, resp, nil +} diff --git a/api/services/workspaces/userlists/userlists_test.go b/api/services/workspaces/userlists/userlists_test.go new file mode 100644 index 0000000..3b277c8 --- /dev/null +++ b/api/services/workspaces/userlists/userlists_test.go @@ -0,0 +1,178 @@ +package userlists + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-sauced/pizza-cli/api/mock" + "github.com/open-sauced/pizza-cli/api/services" +) + +func TestGetUserLists(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + assert.Equal(t, "https://api.example.com/v2/workspaces/abc123/userLists?limit=30&page=1", req.URL.String()) + assert.Equal(t, "GET", req.Method) + + mockResponse := GetUserListsResponse{ + Data: []DbUserList{ + { + ID: "abc", + Name: "userlist1", + }, + { + ID: "xyz", + Name: "userlist2", + }, + }, + Meta: services.MetaData{ + Page: 1, + Limit: 30, + ItemCount: 2, + PageCount: 1, + HasPreviousPage: false, + HasNextPage: false, + }, + } + + // Convert the mock response to JSON + responseBody, _ := json.Marshal(mockResponse) + + // Return the mock response + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBuffer(responseBody)), + }, nil + }) + + client := &http.Client{Transport: m} + service := NewService(client, "https://api.example.com") + + userlists, resp, err := service.GetUserLists("token", "abc123", 1, 30) + + require.NoError(t, err) + assert.NotNil(t, userlists) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Len(t, userlists.Data, 2) + + // First workspace + assert.Equal(t, "abc", userlists.Data[0].ID) + assert.Equal(t, "userlist1", userlists.Data[0].Name) + + // Second workspace + assert.Equal(t, "xyz", userlists.Data[1].ID) + assert.Equal(t, "userlist2", userlists.Data[1].Name) + + // Check the meta information + assert.Equal(t, 1, userlists.Meta.Page) + assert.Equal(t, 30, userlists.Meta.Limit) + assert.Equal(t, 2, userlists.Meta.ItemCount) + assert.Equal(t, 1, userlists.Meta.PageCount) + assert.False(t, userlists.Meta.HasPreviousPage) + assert.False(t, userlists.Meta.HasNextPage) +} + +func TestGetUserListForUser(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + assert.Equal(t, "https://api.example.com/v2/workspaces/abc123/userLists/xyz", req.URL.String()) + assert.Equal(t, "GET", req.Method) + + mockResponse := DbUserList{ + ID: "abc", + Name: "userlist1", + } + + // Convert the mock response to JSON + responseBody, _ := json.Marshal(mockResponse) + + // Return the mock response + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBuffer(responseBody)), + }, nil + }) + + client := &http.Client{Transport: m} + service := NewService(client, "https://api.example.com") + + userlists, resp, err := service.GetUserList("token", "abc123", "xyz") + + require.NoError(t, err) + assert.NotNil(t, userlists) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "abc", userlists.ID) + assert.Equal(t, "userlist1", userlists.Name) +} + +func TestCreateUserListForUser(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + assert.Equal(t, "https://api.example.com/v2/workspaces/abc123/userLists", req.URL.String()) + assert.Equal(t, "POST", req.Method) + + mockResponse := CreateUserListResponse{ + ID: "abc", + UserListID: "xyz", + } + + // Convert the mock response to JSON + responseBody, _ := json.Marshal(mockResponse) + + // Return the mock response + return &http.Response{ + StatusCode: http.StatusCreated, + Body: io.NopCloser(bytes.NewBuffer(responseBody)), + }, nil + }) + + client := &http.Client{Transport: m} + service := NewService(client, "https://api.example.com") + + userlists, resp, err := service.CreateUserListForUser("token", "abc123", "userlist1", []string{}) + + require.NoError(t, err) + assert.NotNil(t, userlists) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + assert.Equal(t, "abc", userlists.ID) + assert.Equal(t, "xyz", userlists.UserListID) +} + +func TestPatchUserListForUser(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + assert.Equal(t, "https://api.example.com/v2/workspaces/abc123/userLists/abc", req.URL.String()) + assert.Equal(t, "PATCH", req.Method) + + mockResponse := DbUserList{ + ID: "abc", + Name: "userlist1", + } + + // Convert the mock response to JSON + responseBody, _ := json.Marshal(mockResponse) + + // Return the mock response + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBuffer(responseBody)), + }, nil + }) + + client := &http.Client{Transport: m} + service := NewService(client, "https://api.example.com") + + userlists, resp, err := service.PatchUserListForUser("token", "abc123", "abc", "userlist1", []string{}) + + require.NoError(t, err) + assert.NotNil(t, userlists) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "abc", userlists.ID) + assert.Equal(t, "userlist1", userlists.Name) +} diff --git a/api/services/workspaces/workspaces.go b/api/services/workspaces/workspaces.go new file mode 100644 index 0000000..0d243b6 --- /dev/null +++ b/api/services/workspaces/workspaces.go @@ -0,0 +1,121 @@ +package workspaces + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + + "github.com/open-sauced/pizza-cli/api/services/workspaces/userlists" +) + +// Service is used to access the "v2/workspaces" endpoints and services. +// It has a child service UserListService used for accessing workspace contributor insights +type Service struct { + UserListService *userlists.Service + + httpClient *http.Client + endpoint string +} + +// NewWorkspacesService returns a new workspace Service +func NewWorkspacesService(httpClient *http.Client, endpoint string) *Service { + userListService := userlists.NewService(httpClient, endpoint) + + return &Service{ + UserListService: userListService, + httpClient: httpClient, + endpoint: endpoint, + } +} + +// GetWorkspaces calls the "GET v2/workspaces" endpoint for the authenticated user +func (s *Service) GetWorkspaces(token string, page, limit int) (*DbWorkspacesResponse, *http.Response, error) { + baseURL := s.endpoint + "/v2/workspaces" + + // Create URL with query parameters + u, err := url.Parse(baseURL) + if err != nil { + return nil, nil, fmt.Errorf("error parsing URL: %v", err) + } + + q := u.Query() + q.Set("page", strconv.Itoa(page)) + q.Set("limit", strconv.Itoa(limit)) + u.RawQuery = q.Encode() + + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, nil, fmt.Errorf("error creating request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) + } + + var workspacesResp DbWorkspacesResponse + if err := json.NewDecoder(resp.Body).Decode(&workspacesResp); err != nil { + return nil, resp, fmt.Errorf("error decoding response: %w", err) + } + + return &workspacesResp, resp, nil +} + +// CreateWorkspaceForUser calls the "POST v2/workspaces" endpoint for the authenticated user +func (s *Service) CreateWorkspaceForUser(token string, name string, description string, repos []string) (*DbWorkspace, *http.Response, error) { + url := s.endpoint + "/v2/workspaces" + + repoReqs := []CreateWorkspaceRequestRepoInfo{} + for _, repo := range repos { + repoReqs = append(repoReqs, CreateWorkspaceRequestRepoInfo{FullName: repo}) + } + + req := CreateWorkspaceRequest{ + Name: name, + Description: description, + Repos: repoReqs, + Members: []string{}, + Contributors: []string{}, + } + + payload, err := json.Marshal(req) + if err != nil { + return nil, nil, fmt.Errorf("error marshaling request: %w", err) + } + + httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(payload)) + if err != nil { + return nil, nil, fmt.Errorf("error creating request: %w", err) + } + + httpReq.Header.Set("Authorization", "Bearer "+token) + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("Accept", "application/json") + + resp, err := s.httpClient.Do(httpReq) + if err != nil { + return nil, resp, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return nil, resp, fmt.Errorf("API request failed with status code: %d", resp.StatusCode) + } + + var createdWorkspace DbWorkspace + if err := json.NewDecoder(resp.Body).Decode(&createdWorkspace); err != nil { + return nil, resp, fmt.Errorf("error decoding response: %w", err) + } + + return &createdWorkspace, resp, nil +} diff --git a/api/services/workspaces/workspaces_test.go b/api/services/workspaces/workspaces_test.go new file mode 100644 index 0000000..3da2b50 --- /dev/null +++ b/api/services/workspaces/workspaces_test.go @@ -0,0 +1,112 @@ +package workspaces + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-sauced/pizza-cli/api/mock" + "github.com/open-sauced/pizza-cli/api/services" +) + +func TestGetWorkspaces(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + assert.Equal(t, "https://api.example.com/v2/workspaces?limit=30&page=1", req.URL.String()) + assert.Equal(t, "GET", req.Method) + + mockResponse := DbWorkspacesResponse{ + Data: []DbWorkspace{ + { + ID: "abc123", + Name: "workspace1", + }, + { + ID: "xyz987", + Name: "workspace2", + }, + }, + Meta: services.MetaData{ + Page: 1, + Limit: 30, + ItemCount: 2, + PageCount: 1, + HasPreviousPage: false, + HasNextPage: false, + }, + } + + // Convert the mock response to JSON + responseBody, _ := json.Marshal(mockResponse) + + // Return the mock response + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBuffer(responseBody)), + }, nil + }) + + client := &http.Client{Transport: m} + service := NewWorkspacesService(client, "https://api.example.com") + + workspaces, resp, err := service.GetWorkspaces("token", 1, 30) + + require.NoError(t, err) + assert.NotNil(t, workspaces) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Len(t, workspaces.Data, 2) + + // First workspace + assert.Equal(t, "abc123", workspaces.Data[0].ID) + assert.Equal(t, "workspace1", workspaces.Data[0].Name) + + // Second workspace + assert.Equal(t, "xyz987", workspaces.Data[1].ID) + assert.Equal(t, "workspace2", workspaces.Data[1].Name) + + // Check the meta information + assert.Equal(t, 1, workspaces.Meta.Page) + assert.Equal(t, 30, workspaces.Meta.Limit) + assert.Equal(t, 2, workspaces.Meta.ItemCount) + assert.Equal(t, 1, workspaces.Meta.PageCount) + assert.False(t, workspaces.Meta.HasPreviousPage) + assert.False(t, workspaces.Meta.HasNextPage) +} + +func TestCreateWorkspaceForUser(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + assert.Equal(t, "https://api.example.com/v2/workspaces", req.URL.String()) + assert.Equal(t, "POST", req.Method) + + mockResponse := DbWorkspace{ + ID: "abc123", + Name: "workspace1", + } + + // Convert the mock response to JSON + responseBody, _ := json.Marshal(mockResponse) + + // Return the mock response + return &http.Response{ + StatusCode: http.StatusCreated, + Body: io.NopCloser(bytes.NewBuffer(responseBody)), + }, nil + }) + + client := &http.Client{Transport: m} + service := NewWorkspacesService(client, "https://api.example.com") + + workspace, resp, err := service.CreateWorkspaceForUser("token", "test workspace", "a workspace for testing", []string{}) + + require.NoError(t, err) + assert.NotNil(t, workspace) + assert.Equal(t, http.StatusCreated, resp.StatusCode) + assert.Equal(t, "abc123", workspace.ID) + assert.Equal(t, "workspace1", workspace.Name) +} diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index 43f9979..802410f 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -32,19 +32,17 @@ func NewLoginCommand() *cobra.Command { Long: loginLongDesc, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - username, err := run() - disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) - if !disableTelem { - opts.telemetry = utils.NewPosthogCliClient() - defer opts.telemetry.Done() + opts.telemetry = utils.NewPosthogCliClient(!disableTelem) + defer opts.telemetry.Done() + + username, err := run() - if err != nil { - opts.telemetry.CaptureFailedLogin() - } else { - opts.telemetry.CaptureLogin(username) - } + if err != nil { + opts.telemetry.CaptureFailedLogin() + } else { + opts.telemetry.CaptureLogin(username) } return err diff --git a/cmd/docs/docs.go b/cmd/docs/docs.go new file mode 100644 index 0000000..c968d55 --- /dev/null +++ b/cmd/docs/docs.go @@ -0,0 +1,78 @@ +package docs + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + "github.com/spf13/cobra/doc" +) + +type Options struct { + Path string +} + +const DefaultPath = "./docs" + +func GetDocsPath(path string) (string, error) { + if path == "" { + fmt.Printf("No path was provided. Using default path: %s\n", DefaultPath) + path = DefaultPath + } + + absPath, err := filepath.Abs(path) + + if err != nil { + return "", fmt.Errorf("error resolving absolute path: %w", err) + } + + _, err = os.Stat(absPath) + + if os.IsNotExist(err) { + fmt.Printf("The directory %s does not exist. Creating it...\n", absPath) + if err := os.MkdirAll(absPath, os.ModePerm); err != nil { + return "", fmt.Errorf("error creating directory %s: %w", absPath, err) + } + } else if err != nil { + return "", fmt.Errorf("error checking directory %s: %w", absPath, err) + } + + return absPath, nil +} + +func GenerateDocumentation(rootCmd *cobra.Command, path string) error { + fmt.Printf("Generating documentation in %s...\n", path) + err := doc.GenMarkdownTree(rootCmd, path) + + if err != nil { + return err + } + + fmt.Printf("Finished generating documentation in %s\n", path) + + return nil +} + +func NewDocsCommand() *cobra.Command { + return &cobra.Command{ + Use: "docs [path]", + Short: "Generates the documentation for the CLI", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.Parent().Root().DisableAutoGenTag = true + + var path string + if len(args) > 0 { + path = args[0] + } + + resolvedPath, err := GetDocsPath(path) + if err != nil { + return err + } + + return GenerateDocumentation(cmd.Parent().Root(), resolvedPath) + }, + } +} diff --git a/cmd/docs/docs_test.go b/cmd/docs/docs_test.go new file mode 100644 index 0000000..62a1df7 --- /dev/null +++ b/cmd/docs/docs_test.go @@ -0,0 +1,82 @@ +package docs + +import ( + "os" + "path/filepath" + "testing" +) + +func TestGetDocsPath(t *testing.T) { + t.Parallel() + + t.Run("No path provided", func(t *testing.T) { + t.Parallel() + actual, err := GetDocsPath("") + + if err != nil { + t.Errorf("GetDocsPath() error = %v, wantErr false", err) + return + } + + expected, _ := filepath.Abs(DefaultPath) + if actual != expected { + t.Errorf("GetDocsPath() = %v, want %v", actual, expected) + } + }) + + t.Run("With path provided", func(t *testing.T) { + t.Parallel() + inputPath := filepath.Join(os.TempDir(), "docs") + actual, err := GetDocsPath(inputPath) + + if err != nil { + t.Errorf("GetDocsPath() error = %v, wantErr false", err) + return + } + + expected, _ := filepath.Abs(inputPath) + if actual != expected { + t.Errorf("GetDocsPath() = %v, want %v", actual, expected) + } + + if _, err := os.Stat(actual); os.IsNotExist(err) { + t.Errorf("GetDocsPath() failed to create directory %s", actual) + } + }) + + t.Run("Invalid path", func(t *testing.T) { + t.Parallel() + invalidPath := string([]byte{0}) + + _, err := GetDocsPath(invalidPath) + + if err == nil { + t.Errorf("GetDocsPath() error = nil, wantErr true") + } + }) +} + +func TestGetDocsPath_ExistingDirectory(t *testing.T) { + t.Parallel() + + tempDir, err := os.MkdirTemp("", "docs_test_existing") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + actual, err := GetDocsPath(tempDir) + + if err != nil { + t.Errorf("GetDocsPath() error = %v, wantErr false", err) + return + } + + expected, _ := filepath.Abs(tempDir) + if actual != expected { + t.Errorf("GetDocsPath() = %v, want %v", actual, expected) + } + + if _, err := os.Stat(actual); os.IsNotExist(err) { + t.Errorf("GetDocsPath() failed to recognize existing directory %s", actual) + } +} diff --git a/cmd/generate/codeowners/codeowners.go b/cmd/generate/codeowners/codeowners.go index 701c61b..3e092e8 100644 --- a/cmd/generate/codeowners/codeowners.go +++ b/cmd/generate/codeowners/codeowners.go @@ -11,8 +11,14 @@ import ( "github.com/jpmcb/gopherlogs/pkg/colors" "github.com/spf13/cobra" + "github.com/open-sauced/pizza-cli/api" + "github.com/open-sauced/pizza-cli/api/auth" + "github.com/open-sauced/pizza-cli/api/services/workspaces" + "github.com/open-sauced/pizza-cli/api/services/workspaces/userlists" "github.com/open-sauced/pizza-cli/pkg/config" + "github.com/open-sauced/pizza-cli/pkg/constants" "github.com/open-sauced/pizza-cli/pkg/logging" + "github.com/open-sauced/pizza-cli/pkg/utils" ) // Options for the codeowners generation command @@ -27,15 +33,22 @@ type Options struct { // the number of days to look back previousDays int + // the session token adding codeowners to a workspace contributor list + token string + + logger gopherlogs.Logger tty bool loglevel int + // telemetry for capturing CLI events via PostHog + telemetry *utils.PosthogCliClient + config *config.Spec } const codeownersLongDesc string = `WARNING: Proof of concept feature. -Generates a CODEOWNERS file for a given git repository. This uses a ~/.sauced.yaml +Generates a CODEOWNERS file for a given git repository. This uses a .sauced.yaml configuration to attribute emails with given entities. The generated file specifies up to 3 owners for EVERY file in the git tree based on the @@ -46,7 +59,7 @@ func NewCodeownersCommand() *cobra.Command { cmd := &cobra.Command{ Use: "codeowners path/to/repo [flags]", - Short: "Generates a CODEOWNERS file for a given repository using a \"~/.sauced.yaml\" config", + Short: "Generates a CODEOWNERS file for a given repository using a \".sauced.yaml\" config", Long: codeownersLongDesc, Args: func(_ *cobra.Command, args []string) error { if len(args) != 1 { @@ -71,8 +84,13 @@ func NewCodeownersCommand() *cobra.Command { RunE: func(cmd *cobra.Command, _ []string) error { var err error + disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) + + opts.telemetry = utils.NewPosthogCliClient(!disableTelem) + defer opts.telemetry.Done() + configPath, _ := cmd.Flags().GetString("config") - opts.config, err = config.LoadConfig(configPath, filepath.Join(opts.path, ".sauced.yaml")) + opts.config, err = config.LoadConfig(configPath) if err != nil { return err } @@ -105,48 +123,236 @@ func NewCodeownersCommand() *cobra.Command { } func run(opts *Options, cmd *cobra.Command) error { - logger, err := gopherlogs.NewLogger( + var err error + opts.logger, err = gopherlogs.NewLogger( gopherlogs.WithLogVerbosity(opts.loglevel), gopherlogs.WithTty(!opts.tty), ) if err != nil { return fmt.Errorf("could not build logger: %w", err) } - logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Built logger with log level: %d\n", opts.loglevel) + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Built logger with log level: %d\n", opts.loglevel) repo, err := git.PlainOpen(opts.path) if err != nil { + opts.telemetry.CaptureFailedCodeownersGenerate() return fmt.Errorf("error opening repo: %w", err) } - logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Opened repo at: %s\n", opts.path) + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Opened repo at: %s\n", opts.path) processOptions := ProcessOptions{ repo, opts.previousDays, opts.path, - logger, + opts.logger, } - logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Looking back %d days\n", opts.previousDays) + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Looking back %d days\n", opts.previousDays) codeowners, err := processOptions.process() if err != nil { + opts.telemetry.CaptureFailedCodeownersGenerate() return fmt.Errorf("error traversing git log: %w", err) } // Bootstrap codeowners - outputPath := "" + var outputPath string if opts.ownersStyleFile { outputPath = filepath.Join(opts.path, "OWNERS") } else { outputPath = filepath.Join(opts.path, "CODEOWNERS") } - logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Processing codeowners file at: %s\n", outputPath) + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Processing codeowners file at: %s\n", outputPath) err = generateOutputFile(codeowners, outputPath, opts, cmd) if err != nil { + opts.telemetry.CaptureFailedCodeownersGenerate() return fmt.Errorf("error generating github style codeowners file: %w", err) } - logger.V(logging.LogInfo).Style(0, colors.FgGreen).Infof("Finished generating file: %s\n", outputPath) + opts.logger.V(logging.LogInfo).Style(0, colors.FgGreen).Infof("Finished generating file: %s\n", outputPath) + opts.telemetry.CaptureCodeownersGenerate() + + // ignore the interactive prompts for CI/CD environments + if opts.tty { + return nil + } + + // 1. Ask if they want to add users to a list + var input string + fmt.Print("Do you want to add these codeowners to an OpenSauced Contributor Insight? (y/n): ") + _, err = fmt.Scanln(&input) + if err != nil { + return fmt.Errorf("could not scan input from terminal: %w", err) + } + + switch input { + case "y", "Y", "yes": + opts.logger.V(logging.LogInfo).Style(0, colors.FgGreen).Infof("Adding codeowners to contributor insight\n") + case "n", "N", "no": + return nil + default: + return errors.New("invalid answer. Please enter y or n") + } + + // 2. Check if user is logged in. Log them in if not. + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Initiating log in flow\n") + authenticator := auth.NewAuthenticator() + err = authenticator.CheckSession() + if err != nil { + opts.logger.V(logging.LogInfo).Style(0, colors.FgRed).Infof("Log in session invalid: %s\n", err) + fmt.Print("Do you want to log into OpenSauced? (y/n): ") + _, err := fmt.Scanln(&input) + if err != nil { + return fmt.Errorf("could not scan input from terminal: %w", err) + } + + switch input { + case "y", "Y", "yes": + user, err := authenticator.Login() + if err != nil { + opts.telemetry.CaptureFailedCodeownersGenerateAuth() + opts.logger.V(logging.LogInfo).Style(0, colors.FgRed).Infof("Error logging in\n") + return fmt.Errorf("could not log in: %w", err) + } + opts.telemetry.CaptureCodeownersGenerateAuth(user) + opts.logger.V(logging.LogInfo).Style(0, colors.FgGreen).Infof("Logged in as: %s\n", user) + + case "n", "N", "no": + return nil + + default: + return errors.New("invalid answer. Please enter y or n") + } + } + + opts.token, err = authenticator.GetSessionToken() + if err != nil { + opts.telemetry.CaptureFailedCodeownersGenerateContributorInsight() + opts.logger.V(logging.LogInfo).Style(0, colors.FgRed).Infof("Error getting session token\n") + return fmt.Errorf("could not get session token: %w", err) + } + + listName := filepath.Base(opts.path) + + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Looking up OpenSauced workspace: Pizza CLI\n") + workspace, err := findCreatePizzaCliWorkspace(opts) + if err != nil { + opts.telemetry.CaptureFailedCodeownersGenerateContributorInsight() + opts.logger.V(logging.LogInfo).Style(0, colors.FgRed).Infof("Error finding Workspace: Pizza CLI\n") + return fmt.Errorf("could not find Pizza CLI workspace: %w", err) + } + opts.logger.V(logging.LogDebug).Style(0, colors.FgGreen).Infof("Found workspace: Pizza CLI\n") + + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Looking up Contributor Insight for local repository: %s\n", listName) + userList, err := updateCreateLocalWorkspaceUserList(opts, listName, workspace, codeowners) + if err != nil { + opts.telemetry.CaptureFailedCodeownersGenerateContributorInsight() + opts.logger.V(logging.LogInfo).Style(0, colors.FgRed).Infof("Error finding Workspace Contributor Insight: %s\n", listName) + return fmt.Errorf("could not find Workspace Contributor Insight: %s - %w", listName, err) + } + opts.logger.V(logging.LogDebug).Style(0, colors.FgGreen).Infof("Updated Contributor Insight for local repository: %s\n", listName) + opts.logger.V(logging.LogInfo).Style(0, colors.FgCyan).Infof("Access list on OpenSauced:\n%s\n", fmt.Sprintf("https://app.opensauced.pizza/workspaces/%s/contributor-insights/%s", workspace.ID, userList.ID)) + opts.telemetry.CaptureCodeownersGenerateContributorInsight() return nil } + +// findCreatePizzaCliWorkspace finds or creates a "Pizza CLI" workspace +// for the authenticated user +func findCreatePizzaCliWorkspace(opts *Options) (*workspaces.DbWorkspace, error) { + nextPage := true + page := 1 + apiClient := api.NewClient("https://api.opensauced.pizza") + + for nextPage { + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Query user workspaces page: %d\n", page) + workspaceResp, _, err := apiClient.WorkspacesService.GetWorkspaces(opts.token, page, 100) + if err != nil { + return nil, err + } + + for _, workspace := range workspaceResp.Data { + if workspace.Name == "Pizza CLI" { + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Found existing workspace named: Pizza CLI\n") + return &workspace, nil + } + } + + nextPage = workspaceResp.Meta.HasNextPage + page++ + } + + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Creating new user workspace: Pizza CLI\n") + newWorkspace, _, err := apiClient.WorkspacesService.CreateWorkspaceForUser(opts.token, "Pizza CLI", "A workspace for the Pizza CLI", []string{}) + if err != nil { + return nil, err + } + + return newWorkspace, nil +} + +// updateCreateLocalWorkspaceUserList updates or creates a workspace contributor list +// for the authenticated user with the given codeowners +func updateCreateLocalWorkspaceUserList(opts *Options, listName string, workspace *workspaces.DbWorkspace, codeowners FileStats) (*userlists.DbUserList, error) { + nextPage := true + page := 1 + apiClient := api.NewClient("https://api.opensauced.pizza") + + var targetUserListID string + + for nextPage { + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Query user Workspace Contributor Insight page: %d\n", page) + userListsResp, _, err := apiClient.WorkspacesService.UserListService.GetUserLists(opts.token, workspace.ID, page, 100) + if err != nil { + return nil, err + } + + nextPage = userListsResp.Meta.HasNextPage + page++ + + for _, userList := range userListsResp.Data { + if userList.Name == listName { + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Found existing Workspace Contributor Insight named: %s\n", listName) + targetUserListID = userList.ID + nextPage = false + } + } + } + + if targetUserListID == "" { + var err error + + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Creating new user Workspace Contributor List: %s\n", listName) + createdUserList, _, err := apiClient.WorkspacesService.UserListService.CreateUserListForUser(opts.token, workspace.ID, listName, []string{}) + if err != nil { + return nil, err + } + + targetUserListID = createdUserList.UserListID + } + + targetUserList, _, err := apiClient.WorkspacesService.UserListService.GetUserList(opts.token, workspace.ID, targetUserListID) + if err != nil { + return nil, err + } + + // create a mapping of author logins to empty structs (i.e., a unique set). + // this de-structures the { filename: author-stats } mapping that originally + // built the codeowners + uniqueLogins := make(map[string]struct{}) + for _, codeowner := range codeowners { + for _, k := range codeowner { + if k.GitHubAlias != "" { + uniqueLogins[k.GitHubAlias] = struct{}{} + } + } + } + + logins := []string{} + for login := range uniqueLogins { + logins = append(logins, login) + } + + opts.logger.V(logging.LogDebug).Style(0, colors.FgBlue).Infof("Updating Contributor Insight with codeowners with GitHub aliases: %v\n", logins) + userlist, _, err := apiClient.WorkspacesService.UserListService.PatchUserListForUser(opts.token, workspace.ID, targetUserList.ID, targetUserList.Name, logins) + return userlist, err +} diff --git a/cmd/generate/codeowners/output.go b/cmd/generate/codeowners/output.go index 0925498..800b103 100644 --- a/cmd/generate/codeowners/output.go +++ b/cmd/generate/codeowners/output.go @@ -130,6 +130,14 @@ func getTopContributorAttributions(authorStats AuthorStats, n int, config *confi } } + if len(topContributors) == 0 { + for _, fallbackAttribution := range config.AttributionFallback { + topContributors = append(topContributors, &CodeownerStat{ + GitHubAlias: fallbackAttribution, + }) + } + } + return topContributors } diff --git a/cmd/generate/codeowners/output_test.go b/cmd/generate/codeowners/output_test.go index 8703665..02ac7c6 100644 --- a/cmd/generate/codeowners/output_test.go +++ b/cmd/generate/codeowners/output_test.go @@ -1,6 +1,12 @@ package codeowners -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/open-sauced/pizza-cli/pkg/config" +) func TestCleanFilename(testRunner *testing.T) { var tests = []struct { @@ -27,3 +33,37 @@ func TestCleanFilename(testRunner *testing.T) { }) } } + +func TestGetTopContributorAttributions(testRunner *testing.T) { + configSpec := config.Spec{ + Attributions: map[string][]string{ + "brandonroberts": {"brandon@opensauced.pizza"}, + }, + AttributionFallback: []string{"open-sauced/engineering"}, + } + + var authorStats = AuthorStats{ + "brandon": {GitHubAlias: "brandon", Email: "brandon@opensauced.pizza", Lines: 20}, + "john": {GitHubAlias: "john", Email: "john@opensauced.pizza", Lines: 15}, + } + + results := getTopContributorAttributions(authorStats, 3, &configSpec) + + assert.Len(testRunner, results, 1, "Expected 1 result") + assert.Equal(testRunner, "brandonroberts", results[0].GitHubAlias, "Expected brandonroberts") +} + +func TestGetFallbackAttributions(testRunner *testing.T) { + configSpec := config.Spec{ + Attributions: map[string][]string{ + "jpmcb": {"jpmcb@opensauced.pizza"}, + "brandonroberts": {"brandon@opensauced.pizza"}, + }, + AttributionFallback: []string{"open-sauced/engineering"}, + } + + results := getTopContributorAttributions(AuthorStats{}, 3, &configSpec) + + assert.Len(testRunner, results, 1, "Expected 1 result") + assert.Equal(testRunner, "open-sauced/engineering", results[0].GitHubAlias, "Expected open-sauced/engineering") +} diff --git a/cmd/insights/contributors.go b/cmd/insights/contributors.go index b861eaa..b1103e4 100644 --- a/cmd/insights/contributors.go +++ b/cmd/insights/contributors.go @@ -34,6 +34,8 @@ type contributorsOptions struct { // Output is the formatting style for command output Output string + + telemetry *utils.PosthogCliClient } // NewContributorsCommand returns a new cobra command for 'pizza insights contributors' @@ -52,11 +54,25 @@ func NewContributorsCommand() *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, _ []string) error { + disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) + + opts.telemetry = utils.NewPosthogCliClient(!disableTelem) + defer opts.telemetry.Done() + endpointURL, _ := cmd.Flags().GetString(constants.FlagNameEndpoint) opts.APIClient = api.NewClient(endpointURL) output, _ := cmd.Flags().GetString(constants.FlagNameOutput) opts.Output = output - return opts.run() + + err := opts.run() + + if err != nil { + opts.telemetry.CaptureInsights() + } else { + opts.telemetry.CaptureFailedInsights() + } + + return err }, } cmd.Flags().StringVarP(&opts.FilePath, constants.FlagNameFile, "f", "", "Path to yaml file containing an array of git repository urls") @@ -149,7 +165,7 @@ func (cis contributorsInsightsSlice) BuildOutput(format string) (string, error) func (cis contributorsInsightsSlice) OutputCSV() (string, error) { if len(cis) == 0 { - return "", fmt.Errorf("repository is either non-existent or has not been indexed yet") + return "", errors.New("repository is either non-existent or has not been indexed yet") } b := new(bytes.Buffer) writer := csv.NewWriter(b) diff --git a/cmd/insights/repositories.go b/cmd/insights/repositories.go index d2492b4..a86a380 100644 --- a/cmd/insights/repositories.go +++ b/cmd/insights/repositories.go @@ -33,6 +33,8 @@ type repositoriesOptions struct { // Output is the formatting style for command output Output string + + telemetry *utils.PosthogCliClient } // NewRepositoriesCommand returns a new cobra command for 'pizza insights repositories' @@ -52,11 +54,25 @@ func NewRepositoriesCommand() *cobra.Command { return nil }, RunE: func(cmd *cobra.Command, _ []string) error { + disableTelem, _ := cmd.Flags().GetBool(constants.FlagNameTelemetry) + + opts.telemetry = utils.NewPosthogCliClient(!disableTelem) + defer opts.telemetry.Done() + endpointURL, _ := cmd.Flags().GetString(constants.FlagNameEndpoint) opts.APIClient = api.NewClient(endpointURL) output, _ := cmd.Flags().GetString(constants.FlagNameOutput) opts.Output = output - return opts.run() + + err := opts.run() + + if err != nil { + opts.telemetry.CaptureInsights() + } else { + opts.telemetry.CaptureFailedInsights() + } + + return err }, } cmd.Flags().StringVarP(&opts.FilePath, constants.FlagNameFile, "f", "", "Path to yaml file containing an array of git repository urls") diff --git a/cmd/insights/user-contributions.go b/cmd/insights/user-contributions.go index e2f0a9d..b729756 100644 --- a/cmd/insights/user-contributions.go +++ b/cmd/insights/user-contributions.go @@ -181,7 +181,7 @@ func (ucig userContributionsInsightGroup) BuildOutput(format string) (string, er func (ucig userContributionsInsightGroup) OutputCSV() (string, error) { if len(ucig.Insights) == 0 { - return "", fmt.Errorf("repository is either non-existent or has not been indexed yet") + return "", errors.New("repository is either non-existent or has not been indexed yet") } b := new(bytes.Buffer) diff --git a/cmd/root/root.go b/cmd/root/root.go index 3e8476a..9dbbc45 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/open-sauced/pizza-cli/cmd/auth" + "github.com/open-sauced/pizza-cli/cmd/docs" "github.com/open-sauced/pizza-cli/cmd/generate" "github.com/open-sauced/pizza-cli/cmd/insights" "github.com/open-sauced/pizza-cli/cmd/version" @@ -35,7 +36,7 @@ func NewRootCommand() (*cobra.Command, error) { 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("config", "c", "~/.sauced.yaml", "The saucectl config") + cmd.PersistentFlags().StringP("config", "c", ".sauced.yaml", "The saucectl config") cmd.PersistentFlags().StringP("log-level", "l", "info", "The logging level. Options: error, warn, info, debug") cmd.PersistentFlags().Bool("tty-disable", false, "Disable log stylization. Suitable for CI/CD and automation") @@ -44,6 +45,11 @@ func NewRootCommand() (*cobra.Command, error) { cmd.AddCommand(insights.NewInsightsCommand()) cmd.AddCommand(version.NewVersionCommand()) + // The docs command is hidden as it's only used by the pizza-cli maintainers + docsCmd := docs.NewDocsCommand() + docsCmd.Hidden = true + cmd.AddCommand(docsCmd) + err := cmd.PersistentFlags().MarkHidden(constants.FlagNameEndpoint) if err != nil { return nil, fmt.Errorf("error marking %s as hidden: %w", constants.FlagNameEndpoint, err) diff --git a/docs/pizza.md b/docs/pizza.md new file mode 100644 index 0000000..49abe05 --- /dev/null +++ b/docs/pizza.md @@ -0,0 +1,30 @@ +## pizza + +OpenSauced CLI + +### Synopsis + +A command line utility for insights, metrics, and all things OpenSauced + +``` +pizza [flags] +``` + +### Options + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -h, --help help for pizza + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza completion](pizza_completion.md) - Generate the autocompletion script for the specified shell +* [pizza generate](pizza_generate.md) - Generates something +* [pizza insights](pizza_insights.md) - Gather insights about git contributors, repositories, users and pull requests +* [pizza login](pizza_login.md) - Log into the CLI via GitHub +* [pizza version](pizza_version.md) - Displays the build version of the CLI + diff --git a/docs/pizza_completion.md b/docs/pizza_completion.md new file mode 100644 index 0000000..65a7507 --- /dev/null +++ b/docs/pizza_completion.md @@ -0,0 +1,33 @@ +## pizza completion + +Generate the autocompletion script for the specified shell + +### Synopsis + +Generate the autocompletion script for pizza for the specified shell. +See each sub-command's help for details on how to use the generated script. + + +### Options + +``` + -h, --help help for completion +``` + +### Options inherited from parent commands + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza](pizza.md) - OpenSauced CLI +* [pizza completion bash](pizza_completion_bash.md) - Generate the autocompletion script for bash +* [pizza completion fish](pizza_completion_fish.md) - Generate the autocompletion script for fish +* [pizza completion powershell](pizza_completion_powershell.md) - Generate the autocompletion script for powershell +* [pizza completion zsh](pizza_completion_zsh.md) - Generate the autocompletion script for zsh + diff --git a/docs/pizza_completion_bash.md b/docs/pizza_completion_bash.md new file mode 100644 index 0000000..5beed9d --- /dev/null +++ b/docs/pizza_completion_bash.md @@ -0,0 +1,52 @@ +## pizza completion bash + +Generate the autocompletion script for bash + +### Synopsis + +Generate the autocompletion script for the bash shell. + +This script depends on the 'bash-completion' package. +If it is not installed already, you can install it via your OS's package manager. + +To load completions in your current shell session: + + source <(pizza completion bash) + +To load completions for every new session, execute once: + +#### Linux: + + pizza completion bash > /etc/bash_completion.d/pizza + +#### macOS: + + pizza completion bash > $(brew --prefix)/etc/bash_completion.d/pizza + +You will need to start a new shell for this setup to take effect. + + +``` +pizza completion bash +``` + +### Options + +``` + -h, --help help for bash + --no-descriptions disable completion descriptions +``` + +### Options inherited from parent commands + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza completion](pizza_completion.md) - Generate the autocompletion script for the specified shell + diff --git a/docs/pizza_completion_fish.md b/docs/pizza_completion_fish.md new file mode 100644 index 0000000..93bb9de --- /dev/null +++ b/docs/pizza_completion_fish.md @@ -0,0 +1,43 @@ +## pizza completion fish + +Generate the autocompletion script for fish + +### Synopsis + +Generate the autocompletion script for the fish shell. + +To load completions in your current shell session: + + pizza completion fish | source + +To load completions for every new session, execute once: + + pizza completion fish > ~/.config/fish/completions/pizza.fish + +You will need to start a new shell for this setup to take effect. + + +``` +pizza completion fish [flags] +``` + +### Options + +``` + -h, --help help for fish + --no-descriptions disable completion descriptions +``` + +### Options inherited from parent commands + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza completion](pizza_completion.md) - Generate the autocompletion script for the specified shell + diff --git a/docs/pizza_completion_powershell.md b/docs/pizza_completion_powershell.md new file mode 100644 index 0000000..bec9a87 --- /dev/null +++ b/docs/pizza_completion_powershell.md @@ -0,0 +1,40 @@ +## pizza completion powershell + +Generate the autocompletion script for powershell + +### Synopsis + +Generate the autocompletion script for powershell. + +To load completions in your current shell session: + + pizza completion powershell | Out-String | Invoke-Expression + +To load completions for every new session, add the output of the above command +to your powershell profile. + + +``` +pizza completion powershell [flags] +``` + +### Options + +``` + -h, --help help for powershell + --no-descriptions disable completion descriptions +``` + +### Options inherited from parent commands + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza completion](pizza_completion.md) - Generate the autocompletion script for the specified shell + diff --git a/docs/pizza_completion_zsh.md b/docs/pizza_completion_zsh.md new file mode 100644 index 0000000..22b1734 --- /dev/null +++ b/docs/pizza_completion_zsh.md @@ -0,0 +1,54 @@ +## pizza completion zsh + +Generate the autocompletion script for zsh + +### Synopsis + +Generate the autocompletion script for the zsh shell. + +If shell completion is not already enabled in your environment you will need +to enable it. You can execute the following once: + + echo "autoload -U compinit; compinit" >> ~/.zshrc + +To load completions in your current shell session: + + source <(pizza completion zsh) + +To load completions for every new session, execute once: + +#### Linux: + + pizza completion zsh > "${fpath[1]}/_pizza" + +#### macOS: + + pizza completion zsh > $(brew --prefix)/share/zsh/site-functions/_pizza + +You will need to start a new shell for this setup to take effect. + + +``` +pizza completion zsh [flags] +``` + +### Options + +``` + -h, --help help for zsh + --no-descriptions disable completion descriptions +``` + +### Options inherited from parent commands + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza completion](pizza_completion.md) - Generate the autocompletion script for the specified shell + diff --git a/docs/pizza_generate.md b/docs/pizza_generate.md new file mode 100644 index 0000000..edd658d --- /dev/null +++ b/docs/pizza_generate.md @@ -0,0 +1,34 @@ +## pizza generate + +Generates something + +### Synopsis + +WARNING: Proof of concept feature. + +XXX + +``` +pizza generate [subcommand] [flags] +``` + +### Options + +``` + -h, --help help for generate +``` + +### Options inherited from parent commands + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza](pizza.md) - OpenSauced CLI +* [pizza generate codeowners](pizza_generate_codeowners.md) - Generates a CODEOWNERS file for a given repository using a "~/.sauced.yaml" config + diff --git a/docs/pizza_generate_codeowners.md b/docs/pizza_generate_codeowners.md new file mode 100644 index 0000000..0cb8357 --- /dev/null +++ b/docs/pizza_generate_codeowners.md @@ -0,0 +1,39 @@ +## pizza generate codeowners + +Generates a CODEOWNERS file for a given repository using a "~/.sauced.yaml" config + +### Synopsis + +WARNING: Proof of concept feature. + +Generates a CODEOWNERS file for a given git repository. This uses a ~/.sauced.yaml +configuration to attribute emails with given entities. + +The generated file specifies up to 3 owners for EVERY file in the git tree based on the +number of lines touched in that specific file over the specified range of time. + +``` +pizza generate codeowners path/to/repo [flags] +``` + +### Options + +``` + -h, --help help for codeowners + --owners-style-file Whether to generate an agnostic OWNERS style file. + -r, --range int The number of days to lookback (default 90) +``` + +### Options inherited from parent commands + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza generate](pizza_generate.md) - Generates something + diff --git a/docs/pizza_insights.md b/docs/pizza_insights.md new file mode 100644 index 0000000..c7be12d --- /dev/null +++ b/docs/pizza_insights.md @@ -0,0 +1,35 @@ +## pizza insights + +Gather insights about git contributors, repositories, users and pull requests + +### Synopsis + +Gather insights about git contributors, repositories, user and pull requests and display the results + +``` +pizza insights [flags] +``` + +### Options + +``` + -h, --help help for insights + -o, --output string The formatting for command output. One of: (table, yaml, csv, json) (default "table") +``` + +### Options inherited from parent commands + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza](pizza.md) - OpenSauced CLI +* [pizza insights contributors](pizza_insights_contributors.md) - Gather insights about contributors of indexed git repositories +* [pizza insights repositories](pizza_insights_repositories.md) - Gather insights about indexed git repositories +* [pizza insights user-contributions](pizza_insights_user-contributions.md) - Gather insights on individual contributors for given repo URLs + diff --git a/docs/pizza_insights_contributors.md b/docs/pizza_insights_contributors.md new file mode 100644 index 0000000..ce13c4e --- /dev/null +++ b/docs/pizza_insights_contributors.md @@ -0,0 +1,34 @@ +## pizza insights contributors + +Gather insights about contributors of indexed git repositories + +### Synopsis + +Gather insights about contributors of indexed git repositories. This command will show new, recent, alumni, repeat contributors for each git repository + +``` +pizza insights contributors url... [flags] +``` + +### Options + +``` + -f, --file string Path to yaml file containing an array of git repository urls + -h, --help help for contributors + -r, --range int Number of days to look-back (7,30,90) (default 30) +``` + +### Options inherited from parent commands + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + -o, --output string The formatting for command output. One of: (table, yaml, csv, json) (default "table") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza insights](pizza_insights.md) - Gather insights about git contributors, repositories, users and pull requests + diff --git a/docs/pizza_insights_repositories.md b/docs/pizza_insights_repositories.md new file mode 100644 index 0000000..64070d4 --- /dev/null +++ b/docs/pizza_insights_repositories.md @@ -0,0 +1,34 @@ +## pizza insights repositories + +Gather insights about indexed git repositories + +### Synopsis + +Gather insights about indexed git repositories. This command will show info about contributors, pull requests, etc. + +``` +pizza insights repositories url... [flags] +``` + +### Options + +``` + -f, --file string Path to yaml file containing an array of git repository urls + -h, --help help for repositories + -p, --range int Number of days to look-back (default 30) +``` + +### Options inherited from parent commands + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + -o, --output string The formatting for command output. One of: (table, yaml, csv, json) (default "table") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza insights](pizza_insights.md) - Gather insights about git contributors, repositories, users and pull requests + diff --git a/docs/pizza_insights_user-contributions.md b/docs/pizza_insights_user-contributions.md new file mode 100644 index 0000000..5ec8b0b --- /dev/null +++ b/docs/pizza_insights_user-contributions.md @@ -0,0 +1,36 @@ +## pizza insights user-contributions + +Gather insights on individual contributors for given repo URLs + +### Synopsis + +Gather insights on individual contributors given a list of repository URLs + +``` +pizza insights user-contributions url... [flags] +``` + +### Options + +``` + -f, --file string Path to yaml file containing an array of git repository urls + -h, --help help for user-contributions + -p, --range int32 Number of days, used for query filtering (default 30) + -s, --sort string Sort user contributions by (total, commits, prs) (default "none") + -u, --users strings Inclusive comma separated list of GitHub usernames to filter for +``` + +### Options inherited from parent commands + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + -o, --output string The formatting for command output. One of: (table, yaml, csv, json) (default "table") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza insights](pizza_insights.md) - Gather insights about git contributors, repositories, users and pull requests + diff --git a/docs/pizza_login.md b/docs/pizza_login.md new file mode 100644 index 0000000..7ca5124 --- /dev/null +++ b/docs/pizza_login.md @@ -0,0 +1,34 @@ +## pizza login + +Log into the CLI via GitHub + +### Synopsis + +Log into the OpenSauced CLI. + +This command initiates the GitHub auth flow to log you into the OpenSauced CLI +by launching your browser and logging in with GitHub. + +``` +pizza login [flags] +``` + +### Options + +``` + -h, --help help for login +``` + +### Options inherited from parent commands + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza](pizza.md) - OpenSauced CLI + diff --git a/docs/pizza_version.md b/docs/pizza_version.md new file mode 100644 index 0000000..5848855 --- /dev/null +++ b/docs/pizza_version.md @@ -0,0 +1,27 @@ +## pizza version + +Displays the build version of the CLI + +``` +pizza version [flags] +``` + +### Options + +``` + -h, --help help for version +``` + +### Options inherited from parent commands + +``` + -c, --config string The saucectl config (default "~/.sauced.yaml") + --disable-telemetry Disable sending telemetry data to OpenSauced + -l, --log-level string The logging level. Options: error, warn, info, debug (default "info") + --tty-disable Disable log stylization. Suitable for CI/CD and automation +``` + +### SEE ALSO + +* [pizza](pizza.md) - OpenSauced CLI + diff --git a/go.mod b/go.mod index dd05559..603715d 100644 --- a/go.mod +++ b/go.mod @@ -17,8 +17,11 @@ require ( ) require ( - github.com/atotto/clipboard v0.1.4 // indirect github.com/charmbracelet/bubbletea v0.27.1 // indirect + github.com/charmbracelet/bubbletea v0.27.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + ) require ( diff --git a/go.sum b/go.sum index 51da393..711eb98 100644 --- a/go.sum +++ b/go.sum @@ -37,6 +37,7 @@ github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEf github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cyphar/filepath-securejoin v0.2.4 h1:Ugdm7cg7i6ZK6x3xDF1oEu1nfkyfH53EtKeQYTC3kyg= github.com/cyphar/filepath-securejoin v0.2.4/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= @@ -109,6 +110,7 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= diff --git a/justfile b/justfile index a0e24e0..79f06de 100644 --- a/justfile +++ b/justfile @@ -1,4 +1,11 @@ -# Builds the go binary into the git ignored ./build/ dir +set dotenv-load + +# Displays this help message +help: + @echo "Available commands:" + @just --list + +# Builds the go binary into the git ignored ./build/ dir for the local architecture build: #!/usr/bin/env sh echo "Building for local arch" @@ -11,17 +18,22 @@ build: -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=$(git rev-parse HEAD)'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}'" \ + -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ -o build/pizza +# Builds and installs the go binary for the local architecture. WARNING: requires sudo access install: build sudo cp "./build/pizza" "/usr/local/bin/" +# Builds all build targets arcross all OS and architectures build-all: \ build \ + build-container \ build-darwin-amd64 build-darwin-arm64 \ build-linux-amd64 build-linux-arm64 \ build-windows-amd64 build-windows-arm64 +# Builds for Darwin linux (i.e., MacOS) on amd64 architecture build-darwin-amd64: #!/usr/bin/env sh @@ -38,8 +50,10 @@ build-darwin-amd64: -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=$(git rev-parse HEAD)'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}'" \ + -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ -o build/pizza-${GOOS}-${GOARCH} +# Builds for Darwin linux (i.e., MacOS) on arm64 architecture (i.e. Apple silicon) build-darwin-arm64: #!/usr/bin/env sh @@ -56,8 +70,10 @@ build-darwin-arm64: -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=$(git rev-parse HEAD)'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}'" \ + -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ -o build/pizza-${GOOS}-${GOARCH} +# Builds for agnostic Linux on amd64 architecture build-linux-amd64: #!/usr/bin/env sh @@ -74,8 +90,10 @@ build-linux-amd64: -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=$(git rev-parse HEAD)'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}'" \ + -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ -o build/pizza-${GOOS}-${GOARCH} +# Builds for agnostic Linux on arm64 architecture build-linux-arm64: #!/usr/bin/env sh @@ -92,8 +110,10 @@ build-linux-arm64: -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=$(git rev-parse HEAD)'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}'" \ + -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ -o build/pizza-${GOOS}-${GOARCH} +# Builds for Windows on amd64 architecture build-windows-amd64: #!/usr/bin/env sh @@ -110,8 +130,10 @@ build-windows-amd64: -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=$(git rev-parse HEAD)'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}'" \ + -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ -o build/pizza-${GOOS}-${GOARCH} +# Builds for Windows on arm64 architecture build-windows-arm64: #!/usr/bin/env sh @@ -128,16 +150,19 @@ build-windows-arm64: -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Version=${VERSION}'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Sha=$(git rev-parse HEAD)'" \ -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.Datetime=${DATETIME}'" \ + -ldflags="-X 'github.com/open-sauced/pizza-cli/pkg/utils.writeOnlyPublicPosthogKey=${POSTHOG_PUBLIC_API_KEY}'" \ -o build/pizza-${GOOS}-${GOARCH} -# Builds the container and marks it tagged as "dev" locally +# Builds the Docker container and tags it as "dev" build-container: docker build \ --build-arg VERSION="$(git describe --tags --always)" \ --build-arg SHA="$(git rev-parse HEAD)" \ --build-arg DATETIME="$(date -u +'%Y-%m-%d %H:%M:%S')" \ + --build-arg POSTHOG_PUBLIC_API_KEY="${POSTHOG_PUBLIC_API_KEY}" \ -t pizza:dev . +# Removes build artifacts clean: rm -rf build/ @@ -148,19 +173,33 @@ test: unit-test unit-test: go test ./... -# Lints the Go code via golangcilint in Docker +# Lints Go code via golangci-lint within Docker lint: docker run \ -t \ --rm \ -v "$(pwd)/:/app" \ -w /app \ - golangci/golangci-lint:v1.59 \ + golangci/golangci-lint:v1.60 \ golangci-lint run -v -# Formats code via builtin go fmt +# Formats Go code via goimports format: find . -type f -name "*.go" -exec goimports -local github.com/open-sauced/pizza-cli -w {} \; +# Installs the dev tools for working with this project. Requires "go", "just", and "docker" +install-dev-tools: + #!/usr/bin/env sh + + go install golang.org/x/tools/cmd/goimports@latest + +# Runs Go code manually through the main.go run: go run main.go + +# Re-generates the docs from the cobra command tree +gen-docs: + go run main.go docs ./docs/ + +# Runs all the dev tasks (like formatting, linting, building, etc.) +dev: format lint test build-all diff --git a/npm/package-lock.json b/npm/package-lock.json index 3d7cc4e..8cd7f72 100644 --- a/npm/package-lock.json +++ b/npm/package-lock.json @@ -1,12 +1,12 @@ { "name": "pizza", - "version": "1.3.0-beta.3", + "version": "1.3.1-beta.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pizza", - "version": "1.3.0-beta.3", + "version": "1.3.1-beta.2", "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/npm/package.json b/npm/package.json index 5d99474..d59677c 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "pizza", - "version": "1.3.0-beta.3", + "version": "1.3.1-beta.2", "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/config/config.go b/pkg/config/config.go index 7dd80c6..ed722c5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -9,24 +9,14 @@ import ( "gopkg.in/yaml.v3" ) -const DefaultConfigPath = "~/.sauced.yaml" - // LoadConfig loads a configuration file at a given path. It attempts to load -// the default location of a ".sauced.yaml" in the user's home directory if an -// empty path is provided. If none is found in the user's home directory, it tries to load -// ".sauced.yaml" from the fallback path, which is the root path of a repository. -func LoadConfig(path string, repoRootPathConfig string) (*Spec, error) { - config := &Spec{} +// the default location of a ".sauced.yaml" in the current working directory if an +// empty path is provided. If none is found, it tries to load +// "~/.sauced.yaml" from the fallback path, which is the user's home directory. +func LoadConfig(path string) (*Spec, error) { + println("Config path loading from -c flag", path) - if path == DefaultConfigPath || path == "" { - // load the default file path under the user's home dir - usr, err := user.Current() - if err != nil { - return nil, fmt.Errorf("could not get user home directory: %w", err) - } - - path = filepath.Join(usr.HomeDir, ".sauced.yaml") - } + config := &Spec{} absPath, err := filepath.Abs(path) if err != nil { @@ -37,9 +27,27 @@ func LoadConfig(path string, repoRootPathConfig string) (*Spec, error) { if err != nil { // If the file does not exist, check if the fallback path exists if os.IsNotExist(err) { - _, err = os.Stat(repoRootPathConfig) + // load the default file path under the user's home dir + usr, err := user.Current() + + if err != nil { + return nil, fmt.Errorf("could not get user home directory: %w", err) + } + + homeDirPathConfig, err := filepath.Abs(filepath.Join(usr.HomeDir, ".sauced.yaml")) + + if err != nil { + return nil, fmt.Errorf("error home directory absolute path: %w", err) + } + + _, err = os.Stat(homeDirPathConfig) + if err != nil { + return nil, fmt.Errorf("error reading config file from %s", homeDirPathConfig) + } + + data, err = os.ReadFile(homeDirPathConfig) if err != nil { - return nil, fmt.Errorf("error reading config file from %s or %s", absPath, repoRootPathConfig) + return nil, fmt.Errorf("error reading config file from %s or %s", absPath, homeDirPathConfig) } } else { return nil, fmt.Errorf("error reading config file: %w", err) diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d554aec..c0da2e6 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -16,11 +16,30 @@ func TestLoadConfig(t *testing.T) { t.Parallel() tmpDir := t.TempDir() configFilePath := filepath.Join(tmpDir, ".sauced.yaml") - require.NoError(t, os.WriteFile(configFilePath, []byte("key: value"), 0644)) - config, err := LoadConfig(configFilePath, "") - assert.NoError(t, err) + fileContents := `# Configuration for attributing commits with emails to GitHub user profiles +# Used during codeowners generation. +# List the emails associated with the given username. +# The commits associated with these emails will be attributed to +# the username in this yaml map. Any number of emails may be listed. +attribution: + brandonroberts: + - robertsbt@gmail.com + jpmcb: + - john@opensauced.pizza` + + require.NoError(t, os.WriteFile(configFilePath, []byte(fileContents), 0600)) + + config, err := LoadConfig(configFilePath) + require.NoError(t, err) assert.NotNil(t, config) + + // Assert that config contains all the Attributions in fileContents + assert.Len(t, config.Attributions, 2) + + // Check specific attributions + assert.Equal(t, []string{"robertsbt@gmail.com"}, config.Attributions["brandonroberts"]) + assert.Equal(t, []string{"john@opensauced.pizza"}, config.Attributions["jpmcb"]) }) t.Run("Non-existent file", func(t *testing.T) { @@ -28,27 +47,49 @@ func TestLoadConfig(t *testing.T) { tmpDir := t.TempDir() nonExistentPath := filepath.Join(tmpDir, ".sauced.yaml") - config, err := LoadConfig(nonExistentPath, "") - assert.Error(t, err) + config, err := LoadConfig(nonExistentPath) + require.Error(t, err) assert.Nil(t, config) }) - t.Run("Non-existent file with fallback", func(t *testing.T) { + t.Run("Providing a custom .sauced.yaml location", func(t *testing.T) { t.Parallel() + fileContents := `# Configuration for attributing commits with emails to GitHub user profiles +# Used during codeowners generation. +# List the emails associated with the given username. +# The commits associated with these emails will be attributed to +# the username in this yaml map. Any number of emails may be listed. +attribution: + brandonroberts: + - robertsbt@gmail.com + jpmcb: + - john@opensauced.pizza + nickytonline: + - nick@nickyt.co + - nick@opensauced.pizza + zeucapua: + - coding@zeu.dev` + tmpDir := t.TempDir() fallbackPath := filepath.Join(tmpDir, ".sauced.yaml") - require.NoError(t, os.WriteFile(fallbackPath, []byte("key: fallback"), 0644)) - nonExistentPath := filepath.Join(tmpDir, "non-existent.yaml") + require.NoError(t, os.WriteFile(fallbackPath, []byte(fileContents), 0600)) + + // Print out the contents of the file we just wrote + _, err := os.ReadFile(fallbackPath) + require.NoError(t, err) - config, err := LoadConfig(nonExistentPath, fallbackPath) - assert.NoError(t, err) + config, err := LoadConfig(fallbackPath) + + require.NoError(t, err) assert.NotNil(t, config) - }) - t.Run("Default path", func(t *testing.T) { - t.Parallel() - config, err := LoadConfig(DefaultConfigPath, "") - assert.Error(t, err) - assert.Nil(t, config) + // Assert that config contains all the Attributions in fileContents + assert.Len(t, config.Attributions, 4) + + // Check specific attributions + assert.Equal(t, []string{"robertsbt@gmail.com"}, config.Attributions["brandonroberts"]) + assert.Equal(t, []string{"john@opensauced.pizza"}, config.Attributions["jpmcb"]) + assert.Equal(t, []string{"nick@nickyt.co", "nick@opensauced.pizza"}, config.Attributions["nickytonline"]) + assert.Equal(t, []string{"coding@zeu.dev"}, config.Attributions["zeucapua"]) }) } diff --git a/pkg/config/spec.go b/pkg/config/spec.go index 22455f1..46b85e3 100644 --- a/pkg/config/spec.go +++ b/pkg/config/spec.go @@ -8,4 +8,8 @@ type Spec struct { // Example: { github_username: [ email1@domain.com, email2@domain.com ]} where // "github_username" has 2 emails attributed to them and their work. Attributions map[string][]string `yaml:"attribution"` + + // AttributionFallback is the default username/group(s) to attribute to the filename + // if no other attributions were found. + AttributionFallback []string `yaml:"attribution-fallback"` } diff --git a/pkg/utils/posthog.go b/pkg/utils/posthog.go index 0d506ef..ac66728 100644 --- a/pkg/utils/posthog.go +++ b/pkg/utils/posthog.go @@ -12,12 +12,13 @@ var ( // 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 + client posthog.Client + activated bool } // NewPosthogCliClient returns a PosthogCliClient which can be used to capture // telemetry events for CLI users -func NewPosthogCliClient() *PosthogCliClient { +func NewPosthogCliClient(activated bool) *PosthogCliClient { client, err := posthog.NewWithConfig( writeOnlyPublicPosthogKey, posthog.Config{ @@ -32,7 +33,8 @@ func NewPosthogCliClient() *PosthogCliClient { } return &PosthogCliClient{ - client: client, + client: client, + activated: activated, } } @@ -44,44 +46,106 @@ 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", - }) + if p.activated { + 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", - }) + if p.activated { + p.client.Enqueue(posthog.Capture{ + DistinctId: "login-failures", + Event: "cli_user failed log in", + }) + } +} + +//nolint:errcheck +func (p *PosthogCliClient) CaptureCodeownersGenerate() { + if p.activated { + p.client.Enqueue(posthog.Capture{ + DistinctId: "codeowners-generated", + Event: "cli generated codeowners", + }) + } +} + +//nolint:errcheck +func (p *PosthogCliClient) CaptureFailedCodeownersGenerate() { + if p.activated { + p.client.Enqueue(posthog.Capture{ + DistinctId: "failed-codeowners-generated", + Event: "cli failed to generate codeowners", + }) + } +} + +//nolint:errcheck +func (p *PosthogCliClient) CaptureCodeownersGenerateAuth(username string) { + if p.activated { + p.client.Enqueue(posthog.Capture{ + DistinctId: username, + Event: "user authenticated during generate codeowners flow", + }) + } +} + +//nolint:errcheck +func (p *PosthogCliClient) CaptureFailedCodeownersGenerateAuth() { + if p.activated { + p.client.Enqueue(posthog.Capture{ + DistinctId: "codeowners-generate-auth-failed", + Event: "user failed to authenticate during generate codeowners flow", + }) + } +} + +//nolint:errcheck +func (p *PosthogCliClient) CaptureCodeownersGenerateContributorInsight() { + if p.activated { + p.client.Enqueue(posthog.Capture{ + DistinctId: "codeowners-generate-contributor-insight", + Event: "cli created/updated contributor list for user", + }) + } } -// 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), - }) +func (p *PosthogCliClient) CaptureFailedCodeownersGenerateContributorInsight() { + if p.activated { + p.client.Enqueue(posthog.Capture{ + DistinctId: "failed-codeowners-generation-contributor-insight", + Event: "cli failed to create/update contributor insight for user", + }) + } +} + +//nolint:errcheck +func (p *PosthogCliClient) CaptureInsights() { + if p.activated { + p.client.Enqueue(posthog.Capture{ + DistinctId: "insights", + Event: "cli called insights command", + }) + } +} + +//nolint:errcheck +func (p *PosthogCliClient) CaptureFailedInsights() { + if p.activated { + p.client.Enqueue(posthog.Capture{ + DistinctId: "failed-insight", + Event: "cli failed to call insights command", + }) + } } diff --git a/pkg/utils/root.go b/pkg/utils/root.go index 6d017d0..fb9369d 100644 --- a/pkg/utils/root.go +++ b/pkg/utils/root.go @@ -20,6 +20,8 @@ func SetupRootCommand(rootCmd *cobra.Command) { // Uses the users terminal size or width of 80 if cannot determine users width func wrappedFlagUsages(cmd *pflag.FlagSet) string { + // converts the uintptr to the system file descriptor integer + //nolint:gosec fd := int(os.Stdout.Fd()) width := 80