diff --git a/api/client.go b/api/client.go new file mode 100644 index 0000000..4429a8e --- /dev/null +++ b/api/client.go @@ -0,0 +1,45 @@ +package api + +import ( + "net/http" + "time" + + "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" +) + +// Client is the API client for OpenSauced API +type Client struct { + // API services + RepositoryService *repository.Service + ContributorService *contributors.Service + HistogramService *histogram.Service + + // The configured http client for making API requests + httpClient *http.Client + + // The API endpoint to use when making requests + // Example: https://api.opensauced.pizza + endpoint string +} + +// NewClient returns a new API Client based on provided inputs +func NewClient(endpoint string) *Client { + httpClient := &http.Client{ + // TODO (jpmcb): in the future, we can allow users to configure the API + // timeout via some global flag + Timeout: time.Second * 10, + } + + client := Client{ + httpClient: httpClient, + endpoint: endpoint, + } + + client.ContributorService = contributors.NewContributorsService(client.httpClient, client.endpoint) + client.RepositoryService = repository.NewRepositoryService(client.httpClient, client.endpoint) + client.HistogramService = histogram.NewHistogramService(client.httpClient, client.endpoint) + + return &client +} diff --git a/api/mock/mock.go b/api/mock/mock.go new file mode 100644 index 0000000..58b5248 --- /dev/null +++ b/api/mock/mock.go @@ -0,0 +1,23 @@ +package mock + +import "net/http" + +// RoundTripper is a custom, mock http.RoundTripper used for testing and mocking +// purposes ONLY. +type RoundTripper struct { + RoundTripFunc func(req *http.Request) (*http.Response, error) +} + +// NewMockRoundTripper returns a new RoundTripper which will execut the given +// roundTripFunc provided by the caller +func NewMockRoundTripper(roundTripFunc func(req *http.Request) (*http.Response, error)) *RoundTripper { + return &RoundTripper{ + RoundTripFunc: roundTripFunc, + } +} + +// RoundTrip fufills the http.Client interface and executes the provided RoundTripFunc +// given by the caller in the NewMockRoundTripper +func (m *RoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + return m.RoundTripFunc(req) +} diff --git a/api/services/contributors/contributors.go b/api/services/contributors/contributors.go new file mode 100644 index 0000000..1d34d38 --- /dev/null +++ b/api/services/contributors/contributors.go @@ -0,0 +1,189 @@ +package contributors + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" +) + +// Service is the Contributors service used for accessing the "v2/contributors" +// endpoint and API services +type Service struct { + httpClient *http.Client + endpoint string +} + +// NewContributorsService returns a new contributors Service +func NewContributorsService(httpClient *http.Client, endpoint string) *Service { + return &Service{ + httpClient: httpClient, + endpoint: endpoint, + } +} + +// 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) + + // 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("range", fmt.Sprintf("%d", rangeVal)) + q.Set("repos", strings.Join(repos, ",")) + u.RawQuery = q.Encode() + + resp, err := s.httpClient.Get(u.String()) + if err != nil { + return nil, resp, fmt.Errorf("error making request: %v", 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 newContributorsResponse ContribResponse + if err := json.NewDecoder(resp.Body).Decode(&newContributorsResponse); err != nil { + return nil, resp, fmt.Errorf("error decoding response: %v", err) + } + + return &newContributorsResponse, resp, nil +} + +// 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) + + // 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("range", fmt.Sprintf("%d", rangeVal)) + q.Set("repos", strings.Join(repos, ",")) + u.RawQuery = q.Encode() + + resp, err := s.httpClient.Get(u.String()) + if err != nil { + return nil, resp, fmt.Errorf("error making request: %v", 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 recentContributorsResponse ContribResponse + if err := json.NewDecoder(resp.Body).Decode(&recentContributorsResponse); err != nil { + return nil, resp, fmt.Errorf("error decoding response: %v", err) + } + + return &recentContributorsResponse, resp, nil +} + +// 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) + + // 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("range", fmt.Sprintf("%d", rangeVal)) + q.Set("repos", strings.Join(repos, ",")) + u.RawQuery = q.Encode() + + resp, err := s.httpClient.Get(u.String()) + if err != nil { + return nil, resp, fmt.Errorf("error making request: %v", 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 alumniContributorsResponse ContribResponse + if err := json.NewDecoder(resp.Body).Decode(&alumniContributorsResponse); err != nil { + return nil, resp, fmt.Errorf("error decoding response: %v", err) + } + + return &alumniContributorsResponse, resp, nil +} + +// 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) + + // 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("range", fmt.Sprintf("%d", rangeVal)) + q.Set("repos", strings.Join(repos, ",")) + u.RawQuery = q.Encode() + + resp, err := s.httpClient.Get(u.String()) + if err != nil { + return nil, resp, fmt.Errorf("error making request: %v", 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 repeatContributorsResponse ContribResponse + if err := json.NewDecoder(resp.Body).Decode(&repeatContributorsResponse); err != nil { + return nil, resp, fmt.Errorf("error decoding response: %v", err) + } + + return &repeatContributorsResponse, resp, nil +} + +// 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) + + // 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("range", fmt.Sprintf("%d", rangeVal)) + q.Set("repos", strings.Join(repos, ",")) + u.RawQuery = q.Encode() + + resp, err := s.httpClient.Get(u.String()) + if err != nil { + return nil, resp, fmt.Errorf("error making request: %v", 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 searchContributorsResponse ContribResponse + if err := json.NewDecoder(resp.Body).Decode(&searchContributorsResponse); err != nil { + return nil, resp, fmt.Errorf("error decoding response: %v", err) + } + + return &searchContributorsResponse, resp, nil +} diff --git a/api/services/contributors/contributors_test.go b/api/services/contributors/contributors_test.go new file mode 100644 index 0000000..c26af8d --- /dev/null +++ b/api/services/contributors/contributors_test.go @@ -0,0 +1,294 @@ +package contributors + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/open-sauced/pizza-cli/api/mock" + "github.com/open-sauced/pizza-cli/api/services" +) + +func TestNewPullrequestContributors(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + // Check if the URL is correct + assert.Equal(t, "https://api.example.com/v2/contributors/insights/new?range=30&repos=testowner%2Ftestrepo", req.URL.String()) + + mockResponse := ContribResponse{ + Data: []DbContributor{ + { + AuthorLogin: "contributor1", + }, + { + AuthorLogin: "contributor2", + }, + }, + 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 := NewContributorsService(client, "https://api.example.com") + + newContribs, resp, err := service.NewPullRequestContributors([]string{"testowner/testrepo"}, 30) + + assert.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") + + // Check the meta information + assert.Equal(t, 1, newContribs.Meta.Page) + assert.Equal(t, 30, newContribs.Meta.Limit) + assert.Equal(t, 2, newContribs.Meta.ItemCount) + assert.Equal(t, 1, newContribs.Meta.PageCount) + assert.False(t, newContribs.Meta.HasPreviousPage) + assert.False(t, newContribs.Meta.HasNextPage) +} + +func TestRecentPullRequestContributors(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + // Check if the URL is correct + assert.Equal(t, "https://api.example.com/v2/contributors/insights/recent?range=30&repos=testowner%2Ftestrepo", req.URL.String()) + + mockResponse := ContribResponse{ + Data: []DbContributor{ + { + AuthorLogin: "contributor1", + }, + { + AuthorLogin: "contributor2", + }, + }, + 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 := NewContributorsService(client, "https://api.example.com") + + recentContribs, resp, err := service.RecentPullRequestContributors([]string{"testowner/testrepo"}, 30) + + assert.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") + + // Check the meta information + assert.Equal(t, 1, recentContribs.Meta.Page) + assert.Equal(t, 30, recentContribs.Meta.Limit) + assert.Equal(t, 2, recentContribs.Meta.ItemCount) + assert.Equal(t, 1, recentContribs.Meta.PageCount) + assert.False(t, recentContribs.Meta.HasPreviousPage) + assert.False(t, recentContribs.Meta.HasNextPage) +} + +func TestAlumniPullRequestContributors(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + // Check if the URL is correct + assert.Equal(t, "https://api.example.com/v2/contributors/insights/alumni?range=30&repos=testowner%2Ftestrepo", req.URL.String()) + + mockResponse := ContribResponse{ + Data: []DbContributor{ + { + AuthorLogin: "contributor1", + }, + { + AuthorLogin: "contributor2", + }, + }, + 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 := NewContributorsService(client, "https://api.example.com") + + alumniContribs, resp, err := service.AlumniPullRequestContributors([]string{"testowner/testrepo"}, 30) + + assert.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") + + // Check the meta information + assert.Equal(t, 1, alumniContribs.Meta.Page) + assert.Equal(t, 30, alumniContribs.Meta.Limit) + assert.Equal(t, 2, alumniContribs.Meta.ItemCount) + assert.Equal(t, 1, alumniContribs.Meta.PageCount) + assert.False(t, alumniContribs.Meta.HasPreviousPage) + assert.False(t, alumniContribs.Meta.HasNextPage) +} + +func TestRepeatPullRequestContributors(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + // Check if the URL is correct + assert.Equal(t, "https://api.example.com/v2/contributors/insights/repeat?range=30&repos=testowner%2Ftestrepo", req.URL.String()) + + mockResponse := ContribResponse{ + Data: []DbContributor{ + { + AuthorLogin: "contributor1", + }, + { + AuthorLogin: "contributor2", + }, + }, + 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 := NewContributorsService(client, "https://api.example.com") + + repeatContribs, resp, err := service.RepeatPullRequestContributors([]string{"testowner/testrepo"}, 30) + + assert.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") + + // Check the meta information + assert.Equal(t, 1, repeatContribs.Meta.Page) + assert.Equal(t, 30, repeatContribs.Meta.Limit) + assert.Equal(t, 2, repeatContribs.Meta.ItemCount) + assert.Equal(t, 1, repeatContribs.Meta.PageCount) + assert.False(t, repeatContribs.Meta.HasPreviousPage) + assert.False(t, repeatContribs.Meta.HasNextPage) +} + +func TestSearchPullRequestContributors(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + // Check if the URL is correct + assert.Equal(t, "https://api.example.com/v2/contributors/search?range=30&repos=testowner%2Ftestrepo", req.URL.String()) + + mockResponse := ContribResponse{ + Data: []DbContributor{ + { + AuthorLogin: "contributor1", + }, + { + AuthorLogin: "contributor2", + }, + }, + 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 := NewContributorsService(client, "https://api.example.com") + + repeatContribs, resp, err := service.SearchPullRequestContributors([]string{"testowner/testrepo"}, 30) + + assert.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") + + // Check the meta information + assert.Equal(t, 1, repeatContribs.Meta.Page) + assert.Equal(t, 30, repeatContribs.Meta.Limit) + assert.Equal(t, 2, repeatContribs.Meta.ItemCount) + assert.Equal(t, 1, repeatContribs.Meta.PageCount) + assert.False(t, repeatContribs.Meta.HasPreviousPage) + assert.False(t, repeatContribs.Meta.HasNextPage) +} diff --git a/api/services/contributors/spec.go b/api/services/contributors/spec.go new file mode 100644 index 0000000..92552dc --- /dev/null +++ b/api/services/contributors/spec.go @@ -0,0 +1,18 @@ +package contributors + +import ( + "time" + + "github.com/open-sauced/pizza-cli/api/services" +) + +type DbContributor struct { + AuthorLogin string `json:"author_login"` + UserID int `json:"user_id"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ContribResponse struct { + Data []DbContributor `json:"data"` + Meta services.MetaData `json:"meta"` +} diff --git a/api/services/histogram/histogram.go b/api/services/histogram/histogram.go new file mode 100644 index 0000000..7dd0f18 --- /dev/null +++ b/api/services/histogram/histogram.go @@ -0,0 +1,55 @@ +package histogram + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" +) + +// Service is used to access the API "v2/histogram" endpoints and services +type Service struct { + httpClient *http.Client + endpoint string +} + +// NewHistogramService returns a new histogram Service +func NewHistogramService(httpClient *http.Client, endpoint string) *Service { + return &Service{ + httpClient: httpClient, + endpoint: endpoint, + } +} + +// 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) + + // 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("range", fmt.Sprintf("%d", rangeVal)) + q.Set("repo", repo) + u.RawQuery = q.Encode() + + resp, err := s.httpClient.Get(u.String()) + if err != nil { + return nil, resp, fmt.Errorf("error making request: %v", 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 prHistogramData []PrHistogramData + if err := json.NewDecoder(resp.Body).Decode(&prHistogramData); err != nil { + return nil, resp, fmt.Errorf("error decoding response: %v", err) + } + + return prHistogramData, resp, nil +} diff --git a/api/services/histogram/histogram_test.go b/api/services/histogram/histogram_test.go new file mode 100644 index 0000000..85446f8 --- /dev/null +++ b/api/services/histogram/histogram_test.go @@ -0,0 +1,51 @@ +package histogram + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/open-sauced/pizza-cli/api/mock" +) + +func TestPrsHistogram(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + // Check if the URL is correct + assert.Equal(t, "https://api.example.com/v2/histogram/pull-requests?range=30&repo=testowner%2Ftestrepo", req.URL.String()) + + mockResponse := []PrHistogramData{ + { + PrCount: 1, + }, + { + PrCount: 2, + }, + } + + // 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 := NewHistogramService(client, "https://api.example.com") + + prs, resp, err := service.PrsHistogram("testowner/testrepo", 30) + + assert.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) +} diff --git a/api/services/histogram/spec.go b/api/services/histogram/spec.go new file mode 100644 index 0000000..bf55c4e --- /dev/null +++ b/api/services/histogram/spec.go @@ -0,0 +1,22 @@ +package histogram + +import "time" + +type PrHistogramData struct { + Bucket time.Time `json:"bucket"` + PrCount int `json:"prs_count"` + AcceptedPrs int `json:"accepted_prs"` + OpenPrs int `json:"open_prs"` + ClosedPrs int `json:"closed_prs"` + DraftPrs int `json:"draft_prs"` + ActivePrs int `json:"active_prs"` + SpamPrs int `json:"spam_prs"` + PRVelocity int `json:"pr_velocity"` + CollaboratorAssociatedPrs int `json:"collaborator_associated_prs"` + ContributorAssociatedPrs int `json:"contributor_associated_prs"` + MemberAssociatedPrs int `json:"member_associated_prs"` + NonAssociatedPrs int `json:"non_associated_prs"` + OwnerAssociatedPrs int `json:"owner_associated_prs"` + CommentsOnPrs int `json:"comments_on_prs"` + ReviewCommentsOnPrs int `json:"review_comments_on_prs"` +} diff --git a/api/services/repository/repository.go b/api/services/repository/repository.go new file mode 100644 index 0000000..56b2f73 --- /dev/null +++ b/api/services/repository/repository.go @@ -0,0 +1,79 @@ +package repository + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +// Service is used to access the "v2/repos" endpoints and services +type Service struct { + httpClient *http.Client + endpoint string +} + +// NewRepositoryService returns a new repository Service +func NewRepositoryService(httpClient *http.Client, endpoint string) *Service { + return &Service{ + httpClient: httpClient, + endpoint: endpoint, + } +} + +// FindOneByOwnerAndRepo calls the "v2/repos/:owner/:name" endpoint +func (rs *Service) FindOneByOwnerAndRepo(owner string, repo string) (*DbRepository, *http.Response, error) { + url := fmt.Sprintf("%s/v2/repos/%s/%s", rs.endpoint, owner, repo) + + resp, err := rs.httpClient.Get(url) + if err != nil { + return nil, resp, fmt.Errorf("error making request: %v", 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 repository DbRepository + if err := json.NewDecoder(resp.Body).Decode(&repository); err != nil { + return nil, resp, fmt.Errorf("error decoding response: %v", err) + } + + return &repository, resp, nil +} + +// FindContributorsByOwnerAndRepo calls the "v2/repos/:owner/:name/contributors" endpoint +func (rs *Service) FindContributorsByOwnerAndRepo(owner string, repo string, rangeVal int) (*ContributorsResponse, *http.Response, error) { + baseURL := fmt.Sprintf("%s/v2/repos/%s/%s/contributors", rs.endpoint, owner, repo) + + // 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("range", fmt.Sprintf("%d", rangeVal)) + u.RawQuery = q.Encode() + + resp, err := rs.httpClient.Get(u.String()) + if err != nil { + return nil, resp, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp, err + } + + var contributorsResp ContributorsResponse + err = json.Unmarshal(body, &contributorsResp) + if err != nil { + return nil, resp, err + } + + return &contributorsResp, resp, nil +} diff --git a/api/services/repository/repository_test.go b/api/services/repository/repository_test.go new file mode 100644 index 0000000..64e4a70 --- /dev/null +++ b/api/services/repository/repository_test.go @@ -0,0 +1,110 @@ +package repository + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/open-sauced/pizza-cli/api/mock" + "github.com/open-sauced/pizza-cli/api/services" +) + +func TestFindOneByOwnerAndRepo(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + // Check if the URL is correct + assert.Equal(t, "https://api.example.com/v2/repos/testowner/testrepo", req.URL.String()) + + mockResponse := DbRepository{ + ID: 1, + FullName: "testowner/testrepo", + } + + // 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 := NewRepositoryService(client, "https://api.example.com") + + repo, resp, err := service.FindOneByOwnerAndRepo("testowner", "testrepo") + + assert.NoError(t, err) + assert.NotNil(t, repo) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, 1, repo.ID) + assert.Equal(t, "testowner/testrepo", repo.FullName) +} + +func TestFindContributorsByOwnerAndRepo(t *testing.T) { + t.Parallel() + m := mock.NewMockRoundTripper(func(req *http.Request) (*http.Response, error) { + assert.Equal(t, "https://api.example.com/v2/repos/testowner/testrepo/contributors?range=30", req.URL.String()) + + mockResponse := ContributorsResponse{ + Data: []DbContributorInfo{ + { + ID: 1, + Login: "contributor1", + }, + { + ID: 2, + Login: "contributor2", + }, + }, + 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 := NewRepositoryService(client, "https://api.example.com") + + contributors, resp, err := service.FindContributorsByOwnerAndRepo("testowner", "testrepo", 30) + + assert.NoError(t, err) + assert.NotNil(t, contributors) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Len(t, contributors.Data, 2) + + // Check the first contributor + assert.Equal(t, 1, contributors.Data[0].ID) + assert.Equal(t, "contributor1", contributors.Data[0].Login) + + // Check the second contributor + assert.Equal(t, 2, contributors.Data[1].ID) + assert.Equal(t, "contributor2", contributors.Data[1].Login) + + // Check the meta information + assert.Equal(t, 1, contributors.Meta.Page) + assert.Equal(t, 30, contributors.Meta.Limit) + assert.Equal(t, 2, contributors.Meta.ItemCount) + assert.Equal(t, 1, contributors.Meta.PageCount) + assert.False(t, contributors.Meta.HasPreviousPage) + assert.False(t, contributors.Meta.HasNextPage) +} diff --git a/api/services/repository/spec.go b/api/services/repository/spec.go new file mode 100644 index 0000000..59ebf40 --- /dev/null +++ b/api/services/repository/spec.go @@ -0,0 +1,102 @@ +package repository + +import "time" + +type DbRepository struct { + ID int `json:"id"` + UserID int `json:"user_id"` + Size int `json:"size"` + Issues int `json:"issues"` + Stars int `json:"stars"` + Forks int `json:"forks"` + Watchers int `json:"watchers"` + Subscribers int `json:"subscribers"` + Network int `json:"network"` + IsFork bool `json:"is_fork"` + IsPrivate bool `json:"is_private"` + IsTemplate bool `json:"is_template"` + IsArchived bool `json:"is_archived"` + IsDisabled bool `json:"is_disabled"` + HasIssues bool `json:"has_issues"` + HasProjects bool `json:"has_projects"` + HasDownloads bool `json:"has_downloads"` + HasWiki bool `json:"has_wiki"` + HasPages bool `json:"has_pages"` + HasDiscussions bool `json:"has_discussions"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PushedAt time.Time `json:"pushed_at"` + DefaultBranch string `json:"default_branch"` + NodeID string `json:"node_id"` + GitURL string `json:"git_url"` + SSHURL string `json:"ssh_url"` + CloneURL string `json:"clone_url"` + SvnURL string `json:"svn_url"` + MirrorURL string `json:"mirror_url"` + Name string `json:"name"` + FullName string `json:"full_name"` + Description string `json:"description"` + Language string `json:"language"` + License string `json:"license"` + URL string `json:"url"` + Homepage string `json:"homepage"` + Topics []string `json:"topics"` + OSSFScorecardTotalScore float64 `json:"ossf_scorecard_total_score"` + OSSFScorecardDependencyUpdateScore float64 `json:"ossf_scorecard_dependency_update_score"` + OSSFScorecardFuzzingScore float64 `json:"ossf_scorecard_fuzzing_score"` + OSSFScorecardMaintainedScore float64 `json:"ossf_scorecard_maintained_score"` + OSSFScorecardUpdatedAt time.Time `json:"ossf_scorecard_updated_at"` + OpenIssuesCount int `json:"open_issues_count"` + ClosedIssuesCount int `json:"closed_issues_count"` + IssuesVelocityCount float64 `json:"issues_velocity_count"` + OpenPRsCount int `json:"open_prs_count"` + ClosedPRsCount int `json:"closed_prs_count"` + MergedPRsCount int `json:"merged_prs_count"` + DraftPRsCount int `json:"draft_prs_count"` + SpamPRsCount int `json:"spam_prs_count"` + PRVelocityCount float64 `json:"pr_velocity_count"` + ForkVelocity float64 `json:"fork_velocity"` + PRActiveCount int `json:"pr_active_count"` + ActivityRatio float64 `json:"activity_ratio"` + ContributorConfidence float64 `json:"contributor_confidence"` + Health float64 `json:"health"` + LastPushedAt time.Time `json:"last_pushed_at"` + LastMainPushedAt time.Time `json:"last_main_pushed_at"` +} + +// DbContributorInfo represents the structure of a single contributor +type DbContributorInfo struct { + ID int `json:"id"` + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` + Company string `json:"company"` + Location string `json:"location"` + OSCR float64 `json:"oscr"` + Repos []string `json:"repos"` + Tags []string `json:"tags"` + Commits int `json:"commits"` + PRsCreated int `json:"prs_created"` + PRsReviewed int `json:"prs_reviewed"` + IssuesCreated int `json:"issues_created"` + CommitComments int `json:"commit_comments"` + IssueComments int `json:"issue_comments"` + PRReviewComments int `json:"pr_review_comments"` + Comments int `json:"comments"` + TotalContributions int `json:"total_contributions"` + LastContributed time.Time `json:"last_contributed"` + DevstatsUpdatedAt time.Time `json:"devstats_updated_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// ContributorsResponse represents the structure of the contributors endpoint response +type ContributorsResponse struct { + Data []DbContributorInfo `json:"data"` + Meta struct { + Page int `json:"page"` + Limit int `json:"limit"` + ItemCount int `json:"itemCount"` + PageCount int `json:"pageCount"` + HasPreviousPage bool `json:"hasPreviousPage"` + HasNextPage bool `json:"hasNextPage"` + } `json:"meta"` +} diff --git a/api/services/spec.go b/api/services/spec.go new file mode 100644 index 0000000..a9499bf --- /dev/null +++ b/api/services/spec.go @@ -0,0 +1,10 @@ +package services + +type MetaData struct { + Page int `json:"page"` + Limit int `json:"limit"` + ItemCount int `json:"itemCount"` + PageCount int `json:"pageCount"` + HasPreviousPage bool `json:"hasPreviousPage"` + HasNextPage bool `json:"hasNextPage"` +} diff --git a/api/utils/validators.go b/api/utils/validators.go new file mode 100644 index 0000000..b4780c0 --- /dev/null +++ b/api/utils/validators.go @@ -0,0 +1,7 @@ +package api + +// IsValidRange ensures that an API range input is within the valid range of +// acceptable ranges +func IsValidRange(period int) bool { + return period == 7 || period == 30 || period == 90 +} diff --git a/cmd/insights/contributors.go b/cmd/insights/contributors.go index cd320ef..b861eaa 100644 --- a/cmd/insights/contributors.go +++ b/cmd/insights/contributors.go @@ -2,7 +2,6 @@ package insights import ( "bytes" - "context" "encoding/csv" "errors" "fmt" @@ -11,17 +10,18 @@ import ( "sync" bubblesTable "github.com/charmbracelet/bubbles/table" - "github.com/open-sauced/go-api/client" "github.com/spf13/cobra" - "github.com/open-sauced/pizza-cli/pkg/api" + "github.com/open-sauced/pizza-cli/api" + "github.com/open-sauced/pizza-cli/api/services/contributors" + apiUtils "github.com/open-sauced/pizza-cli/api/utils" "github.com/open-sauced/pizza-cli/pkg/constants" "github.com/open-sauced/pizza-cli/pkg/utils" ) type contributorsOptions struct { // APIClient is the http client for making calls to the open-sauced api - APIClient *client.APIClient + APIClient *api.Client // Repos is the array of git repository urls Repos []string @@ -29,8 +29,8 @@ type contributorsOptions struct { // FilePath is the path to yaml file containing an array of git repository urls FilePath string - // Period is the number of days, used for query filtering - Period int32 + // RangeVal is the number of days, used for query filtering + RangeVal int // Output is the formatting style for command output Output string @@ -53,20 +53,20 @@ func NewContributorsCommand() *cobra.Command { }, RunE: func(cmd *cobra.Command, _ []string) error { endpointURL, _ := cmd.Flags().GetString(constants.FlagNameEndpoint) - opts.APIClient = api.NewGoClient(endpointURL) + opts.APIClient = api.NewClient(endpointURL) output, _ := cmd.Flags().GetString(constants.FlagNameOutput) opts.Output = output - return opts.run(context.TODO()) + return opts.run() }, } cmd.Flags().StringVarP(&opts.FilePath, constants.FlagNameFile, "f", "", "Path to yaml file containing an array of git repository urls") - cmd.Flags().Int32VarP(&opts.Period, constants.FlagNamePeriod, "p", 30, "Number of days, used for query filtering (7,30,90)") + cmd.Flags().IntVarP(&opts.RangeVal, constants.FlagNameRange, "r", 30, "Number of days to look-back (7,30,90)") return cmd } -func (opts *contributorsOptions) run(ctx context.Context) error { - if !api.IsValidRange(opts.Period) { - return fmt.Errorf("invalid period: %d, accepts (7,30,90)", opts.Period) +func (opts *contributorsOptions) run() error { + if !apiUtils.IsValidRange(opts.RangeVal) { + return fmt.Errorf("invalid period: %d, accepts (7,30,90)", opts.RangeVal) } repositories, err := utils.HandleRepositoryValues(opts.Repos, opts.FilePath) @@ -87,7 +87,7 @@ func (opts *contributorsOptions) run(ctx context.Context) error { waitGroup.Add(1) go func() { defer waitGroup.Done() - allData, err := findAllContributorsInsights(ctx, opts, repoURL) + allData, err := findAllContributorsInsights(opts, repoURL) if err != nil { errorChan <- err return @@ -210,26 +210,29 @@ func (cis contributorsInsightsSlice) OutputTable() (string, error) { return strings.Join(tables, separator), nil } -func findAllContributorsInsights(ctx context.Context, opts *contributorsOptions, repoURL string) (*contributorsInsights, error) { - repo, err := findRepositoryByOwnerAndRepoName(ctx, opts.APIClient, repoURL) +func findAllContributorsInsights(opts *contributorsOptions, repoURL string) (*contributorsInsights, error) { + var ( + waitGroup = new(sync.WaitGroup) + errorChan = make(chan error, 4) + ) + + repo, err := findRepositoryByOwnerAndRepoName(opts.APIClient, repoURL) if err != nil { return nil, fmt.Errorf("could not get contributors insights for repository %s: %w", repoURL, err) } if repo == nil { return nil, nil } + repoContributorsInsights := &contributorsInsights{ - RepoID: int(repo.Id), - RepoURL: repo.SvnUrl, + RepoID: repo.ID, + RepoURL: repo.SvnURL, } - var ( - waitGroup = new(sync.WaitGroup) - errorChan = make(chan error, 4) - ) + waitGroup.Add(1) go func() { defer waitGroup.Done() - response, err := findNewRepositoryContributors(ctx, opts.APIClient, repo.FullName, opts.Period) + response, err := findNewRepositoryContributors(opts.APIClient, repo.FullName, opts.RangeVal) if err != nil { errorChan <- err return @@ -238,10 +241,11 @@ func findAllContributorsInsights(ctx context.Context, opts *contributorsOptions, repoContributorsInsights.New = append(repoContributorsInsights.New, data.AuthorLogin) } }() + waitGroup.Add(1) go func() { defer waitGroup.Done() - response, err := findRecentRepositoryContributors(ctx, opts.APIClient, repo.FullName, opts.Period) + response, err := findRecentRepositoryContributors(opts.APIClient, repo.FullName, opts.RangeVal) if err != nil { errorChan <- err return @@ -250,10 +254,11 @@ func findAllContributorsInsights(ctx context.Context, opts *contributorsOptions, repoContributorsInsights.Recent = append(repoContributorsInsights.Recent, data.AuthorLogin) } }() + waitGroup.Add(1) go func() { defer waitGroup.Done() - response, err := findAlumniRepositoryContributors(ctx, opts.APIClient, repo.FullName, opts.Period) + response, err := findAlumniRepositoryContributors(opts.APIClient, repo.FullName, opts.RangeVal) if err != nil { errorChan <- err return @@ -262,10 +267,11 @@ func findAllContributorsInsights(ctx context.Context, opts *contributorsOptions, repoContributorsInsights.Alumni = append(repoContributorsInsights.Alumni, data.AuthorLogin) } }() + waitGroup.Add(1) go func() { defer waitGroup.Done() - response, err := findRepeatRepositoryContributors(ctx, opts.APIClient, repo.FullName, opts.Period) + response, err := findRepeatRepositoryContributors(opts.APIClient, repo.FullName, opts.RangeVal) if err != nil { errorChan <- err return @@ -274,6 +280,7 @@ func findAllContributorsInsights(ctx context.Context, opts *contributorsOptions, repoContributorsInsights.Repeat = append(repoContributorsInsights.Repeat, data.AuthorLogin) } }() + waitGroup.Wait() close(errorChan) if len(errorChan) > 0 { @@ -286,50 +293,38 @@ func findAllContributorsInsights(ctx context.Context, opts *contributorsOptions, return repoContributorsInsights, nil } -func findNewRepositoryContributors(ctx context.Context, apiClient *client.APIClient, repo string, period int32) (*client.SearchAllPullRequestContributors200Response, error) { - data, _, err := apiClient.ContributorsServiceAPI. - NewPullRequestContributors(ctx). - Repos(repo). - Range_(period). - Execute() +func findNewRepositoryContributors(apiClient *api.Client, repo string, period int) (*contributors.ContribResponse, error) { + response, _, err := apiClient.ContributorService.NewPullRequestContributors([]string{repo}, period) if err != nil { - return nil, fmt.Errorf("error while calling 'ContributorsServiceAPI.NewPullRequestContributors' with repository %s': %w", repo, err) + return nil, fmt.Errorf("error while calling 'ContributorsService.NewPullRequestContributors' with repository %s': %w", repo, err) } - return data, nil + + return response, nil } -func findRecentRepositoryContributors(ctx context.Context, apiClient *client.APIClient, repo string, period int32) (*client.SearchAllPullRequestContributors200Response, error) { - data, _, err := apiClient.ContributorsServiceAPI. - FindAllRecentPullRequestContributors(ctx). - Repos(repo). - Range_(period). - Execute() +func findRecentRepositoryContributors(apiClient *api.Client, repo string, period int) (*contributors.ContribResponse, error) { + response, _, err := apiClient.ContributorService.RecentPullRequestContributors([]string{repo}, period) if err != nil { - return nil, fmt.Errorf("error while calling 'ContributorsServiceAPI.FindAllRecentPullRequestContributors' with repository %s': %w", repo, err) + return nil, fmt.Errorf("error while calling 'ContributorsService.RecentPullRequestContributors' with repository %s': %w", repo, err) } - return data, nil + + return response, nil } -func findAlumniRepositoryContributors(ctx context.Context, apiClient *client.APIClient, repo string, period int32) (*client.SearchAllPullRequestContributors200Response, error) { - data, _, err := apiClient.ContributorsServiceAPI. - FindAllChurnPullRequestContributors(ctx). - Repos(repo). - Range_(period). - Execute() +func findAlumniRepositoryContributors(apiClient *api.Client, repo string, period int) (*contributors.ContribResponse, error) { + response, _, err := apiClient.ContributorService.AlumniPullRequestContributors([]string{repo}, period) if err != nil { - return nil, fmt.Errorf("error while calling 'ContributorsServiceAPI.FindAllChurnPullRequestContributors' with repository %s': %w", repo, err) + return nil, fmt.Errorf("error while calling 'ContributorsService.AlumniPullRequestContributors' with repository %s': %w", repo, err) } - return data, nil + + return response, nil } -func findRepeatRepositoryContributors(ctx context.Context, apiClient *client.APIClient, repo string, period int32) (*client.SearchAllPullRequestContributors200Response, error) { - data, _, err := apiClient.ContributorsServiceAPI. - FindAllRepeatPullRequestContributors(ctx). - Repos(repo). - Range_(period). - Execute() +func findRepeatRepositoryContributors(apiClient *api.Client, repo string, period int) (*contributors.ContribResponse, error) { + response, _, err := apiClient.ContributorService.RepeatPullRequestContributors([]string{repo}, period) if err != nil { - return nil, fmt.Errorf("error while calling 'ContributorsServiceAPI.FindAllRepeatPullRequestContributors' with repository %s: %w", repo, err) + return nil, fmt.Errorf("error while calling 'ContributorsService.RepeatPullRequestContributors' with repository %s': %w", repo, err) } - return data, nil + + return response, nil } diff --git a/cmd/insights/repositories.go b/cmd/insights/repositories.go index 3a55148..d2492b4 100644 --- a/cmd/insights/repositories.go +++ b/cmd/insights/repositories.go @@ -1,26 +1,25 @@ package insights import ( - "context" "errors" "fmt" - "slices" "strconv" "strings" "sync" bubblesTable "github.com/charmbracelet/bubbles/table" - "github.com/open-sauced/go-api/client" "github.com/spf13/cobra" - "github.com/open-sauced/pizza-cli/pkg/api" + "github.com/open-sauced/pizza-cli/api" + "github.com/open-sauced/pizza-cli/api/services/contributors" + "github.com/open-sauced/pizza-cli/api/services/histogram" "github.com/open-sauced/pizza-cli/pkg/constants" "github.com/open-sauced/pizza-cli/pkg/utils" ) type repositoriesOptions struct { // APIClient is the http client for making calls to the open-sauced api - APIClient *client.APIClient + APIClient *api.Client // Repos is the array of git repository urls Repos []string @@ -28,9 +27,9 @@ type repositoriesOptions struct { // FilePath is the path to yaml file containing an array of git repository urls FilePath string - // Period is the number of days, used for query filtering + // RangeVal is the number of days, used for query filtering // Constrained to either 30 or 60 - Period int32 + RangeVal int // Output is the formatting style for command output Output string @@ -54,18 +53,18 @@ func NewRepositoriesCommand() *cobra.Command { }, RunE: func(cmd *cobra.Command, _ []string) error { endpointURL, _ := cmd.Flags().GetString(constants.FlagNameEndpoint) - opts.APIClient = api.NewGoClient(endpointURL) + opts.APIClient = api.NewClient(endpointURL) output, _ := cmd.Flags().GetString(constants.FlagNameOutput) opts.Output = output - return opts.run(context.TODO()) + return opts.run() }, } cmd.Flags().StringVarP(&opts.FilePath, constants.FlagNameFile, "f", "", "Path to yaml file containing an array of git repository urls") - cmd.Flags().Int32VarP(&opts.Period, constants.FlagNamePeriod, "p", 30, "Number of days, used for query filtering") + cmd.Flags().IntVarP(&opts.RangeVal, constants.FlagNameRange, "p", 30, "Number of days to look-back") return cmd } -func (opts *repositoriesOptions) run(ctx context.Context) error { +func (opts *repositoriesOptions) run() error { repositories, err := utils.HandleRepositoryValues(opts.Repos, opts.FilePath) if err != nil { return err @@ -84,7 +83,7 @@ func (opts *repositoriesOptions) run(ctx context.Context) error { waitGroup.Add(1) go func() { defer waitGroup.Done() - allData, err := findAllRepositoryInsights(ctx, opts, repoURL) + allData, err := findAllRepositoryInsights(opts, repoURL) if err != nil { errorChan <- err return @@ -179,8 +178,8 @@ func (ris repositoryInsightsSlice) OutputTable() (string, error) { return strings.Join(tables, separator), nil } -func findAllRepositoryInsights(ctx context.Context, opts *repositoriesOptions, repoURL string) (*repositoryInsights, error) { - repo, err := findRepositoryByOwnerAndRepoName(ctx, opts.APIClient, repoURL) +func findAllRepositoryInsights(opts *repositoriesOptions, repoURL string) (*repositoryInsights, error) { + repo, err := findRepositoryByOwnerAndRepoName(opts.APIClient, repoURL) if err != nil { return nil, fmt.Errorf("could not get repository insights for repository %s: %w", repoURL, err) } @@ -188,35 +187,41 @@ func findAllRepositoryInsights(ctx context.Context, opts *repositoriesOptions, r return nil, nil } repoInsights := &repositoryInsights{ - RepoID: int(repo.Id), - RepoURL: repo.SvnUrl, + RepoID: repo.ID, + RepoURL: repo.SvnURL, } + var ( waitGroup = new(sync.WaitGroup) errorChan = make(chan error, 4) ) + waitGroup.Add(1) go func() { defer waitGroup.Done() - response, err := getPullRequestInsights(ctx, opts.APIClient, repo.FullName, opts.Period) + response, err := getPullRequestInsights(opts.APIClient, repo.FullName, opts.RangeVal) if err != nil { errorChan <- err return } - repoInsights.AllPullRequests = int(response.PrCount) - repoInsights.AcceptedPullRequests = int(response.AcceptedPrs) - repoInsights.SpamPullRequests = int(response.SpamPrs) + + for _, bucket := range response { + repoInsights.AllPullRequests += bucket.PrCount + repoInsights.AcceptedPullRequests += bucket.AcceptedPrs + repoInsights.SpamPullRequests += bucket.SpamPrs + } }() + waitGroup.Add(1) go func() { defer waitGroup.Done() - response, err := searchAllPullRequestContributors(ctx, opts.APIClient, repo.Id, opts.Period) + response, err := searchAllPullRequestContributors(opts.APIClient, []string{repo.FullName}, opts.RangeVal) if err != nil { errorChan <- err return } var contributors []string - for _, contributor := range response { + for _, contributor := range response.Data { contributors = append(contributors, contributor.AuthorLogin) } repoInsights.Contributors = contributors @@ -230,47 +235,28 @@ func findAllRepositoryInsights(ctx context.Context, opts *repositoriesOptions, r } return nil, allErrors } + return repoInsights, nil } -func getPullRequestInsights(ctx context.Context, apiClient *client.APIClient, repo string, period int32) (*client.DbPullRequestGitHubEventsHistogram, error) { - data, _, err := apiClient.HistogramGenerationServiceAPI. - PrsHistogram(ctx). - Repo(repo). - Execute() +func getPullRequestInsights(apiClient *api.Client, repo string, rangeVal int) ([]histogram.PrHistogramData, error) { + data, _, err := apiClient.HistogramService.PrsHistogram(repo, rangeVal) if err != nil { return nil, fmt.Errorf("error while calling 'PullRequestsServiceAPI.GetPullRequestInsights' with repository %s': %w", repo, err) } - index := slices.IndexFunc(data, func(prHisto client.DbPullRequestGitHubEventsHistogram) bool { - return int32(prHisto.Bucket.Unix()) == period - }) - if index == -1 { - return nil, fmt.Errorf("could not find pull request insights for repository %s with interval %d", repo, period) + + if len(data) == 0 { + return nil, fmt.Errorf("could not find pull request insights for repository %s with interval %d", repo, rangeVal) } - return &data[index], nil + + return data, nil } -func searchAllPullRequestContributors(ctx context.Context, apiClient *client.APIClient, repoID, period int32) ([]client.DbPullRequestContributor, error) { - var ( - allData []client.DbPullRequestContributor - page int32 = 1 - ) - for { - data, _, err := apiClient.ContributorsServiceAPI. - SearchAllPullRequestContributors(ctx). - RepoIds(strconv.Itoa(int(repoID))). - Range_(period). - Limit(50). - Page(page). - Execute() - if err != nil { - return nil, fmt.Errorf("error while calling 'ContributorsServiceAPI.SearchAllPullRequestContributors' with repository %d': %w", repoID, err) - } - allData = append(allData, data.Data...) - if !data.Meta.HasNextPage { - break - } - page++ +func searchAllPullRequestContributors(apiClient *api.Client, repos []string, rangeVal int) (*contributors.ContribResponse, error) { + data, _, err := apiClient.ContributorService.SearchPullRequestContributors(repos, rangeVal) + if err != nil { + return nil, fmt.Errorf("error while calling 'ContributorService.SearchPullRequestContributors' with repository %v': %w", repos, err) } - return allData, nil + + return data, nil } diff --git a/cmd/insights/user-contributions.go b/cmd/insights/user-contributions.go index eb6de93..e2f0a9d 100644 --- a/cmd/insights/user-contributions.go +++ b/cmd/insights/user-contributions.go @@ -2,7 +2,6 @@ package insights import ( "bytes" - "context" "encoding/csv" "errors" "fmt" @@ -11,17 +10,16 @@ import ( "sync" bubblesTable "github.com/charmbracelet/bubbles/table" - "github.com/open-sauced/go-api/client" "github.com/spf13/cobra" - "github.com/open-sauced/pizza-cli/pkg/api" + "github.com/open-sauced/pizza-cli/api" "github.com/open-sauced/pizza-cli/pkg/constants" "github.com/open-sauced/pizza-cli/pkg/utils" ) type userContributionsOptions struct { // APIClient is the http client for making calls to the open-sauced api - APIClient *client.APIClient + APIClient *api.Client // Repos is the array of git repository urls Repos []string @@ -63,22 +61,22 @@ func NewUserContributionsCommand() *cobra.Command { }, RunE: func(cmd *cobra.Command, _ []string) error { endpointURL, _ := cmd.Flags().GetString(constants.FlagNameEndpoint) - opts.APIClient = api.NewGoClient(endpointURL) + opts.APIClient = api.NewClient(endpointURL) output, _ := cmd.Flags().GetString(constants.FlagNameOutput) opts.Output = output - return opts.run(context.TODO()) + return opts.run() }, } cmd.Flags().StringVarP(&opts.FilePath, constants.FlagNameFile, "f", "", "Path to yaml file containing an array of git repository urls") - cmd.Flags().Int32VarP(&opts.Period, constants.FlagNamePeriod, "p", 30, "Number of days, used for query filtering") + cmd.Flags().Int32VarP(&opts.Period, constants.FlagNameRange, "p", 30, "Number of days, used for query filtering") cmd.Flags().StringSliceVarP(&opts.Users, "users", "u", []string{}, "Inclusive comma separated list of GitHub usernames to filter for") cmd.Flags().StringVarP(&opts.Sort, "sort", "s", "none", "Sort user contributions by (total, commits, prs)") return cmd } -func (opts *userContributionsOptions) run(ctx context.Context) error { +func (opts *userContributionsOptions) run() error { repositories, err := utils.HandleRepositoryValues(opts.Repos, opts.FilePath) if err != nil { return err @@ -107,7 +105,7 @@ func (opts *userContributionsOptions) run(ctx context.Context) error { go func() { defer waitGroup.Done() - data, err := findAllUserContributionsInsights(ctx, opts, repoURL) + data, err := findAllUserContributionsInsights(opts, repoURL) if err != nil { errorChan <- err return @@ -258,7 +256,7 @@ func (ucig userContributionsInsightGroup) OutputTable() (string, error) { return fmt.Sprintf("%s\n%s\n", ucig.RepoURL, utils.OutputTable(rows, columns)), nil } -func findAllUserContributionsInsights(ctx context.Context, opts *userContributionsOptions, repoURL string) (*userContributionsInsightGroup, error) { +func findAllUserContributionsInsights(opts *userContributionsOptions, repoURL string) (*userContributionsInsightGroup, error) { owner, name, err := utils.GetOwnerAndRepoFromURL(repoURL) if err != nil { return nil, err @@ -268,15 +266,10 @@ func findAllUserContributionsInsights(ctx context.Context, opts *userContributio RepoURL: repoURL, } - dataPoints, _, err := opts. - APIClient. - RepositoryServiceAPI. - FindContributorsByOwnerAndRepo(ctx, owner, name). - Range_(opts.Period). - Execute() + dataPoints, _, err := opts.APIClient.RepositoryService.FindContributorsByOwnerAndRepo(owner, name, 30) if err != nil { - return nil, fmt.Errorf("error while calling 'RepositoryServiceAPI.FindAllContributorsByRepoId' with repository %s/%s': %w", owner, name, err) + return nil, fmt.Errorf("error while calling API RepositoryService.FindContributorsByOwnerAndRepo with repository %s/%s': %w", owner, name, err) } for _, data := range dataPoints.Data { @@ -284,9 +277,9 @@ func findAllUserContributionsInsights(ctx context.Context, opts *userContributio if len(opts.usersMap) == 0 || ok { repoUserContributionsInsightGroup.Insights = append(repoUserContributionsInsightGroup.Insights, userContributionsInsights{ Login: data.Login, - Commits: int(data.Commits), - PrsCreated: int(data.PrsCreated), - TotalContributions: int(data.Commits) + int(data.PrsCreated), + Commits: data.Commits, + PrsCreated: data.PRsCreated, + TotalContributions: data.Commits + data.PRsCreated, }) } } diff --git a/cmd/insights/utils.go b/cmd/insights/utils.go index a7bca74..d81b02d 100644 --- a/cmd/insights/utils.go +++ b/cmd/insights/utils.go @@ -1,24 +1,23 @@ package insights import ( - "context" "fmt" "net/http" - "github.com/open-sauced/go-api/client" - + "github.com/open-sauced/pizza-cli/api" + "github.com/open-sauced/pizza-cli/api/services/repository" "github.com/open-sauced/pizza-cli/pkg/utils" ) // findRepositoryByOwnerAndRepoName returns an API client Db Repo // based on the given repository URL -func findRepositoryByOwnerAndRepoName(ctx context.Context, apiClient *client.APIClient, repoURL string) (*client.DbRepo, error) { +func findRepositoryByOwnerAndRepoName(apiClient *api.Client, repoURL string) (*repository.DbRepository, error) { owner, repoName, err := utils.GetOwnerAndRepoFromURL(repoURL) if err != nil { return nil, fmt.Errorf("could not extract owner and repo from url: %w", err) } - repo, response, err := apiClient.RepositoryServiceAPI.FindOneByOwnerAndRepo(ctx, owner, repoName).Execute() + repo, response, err := apiClient.RepositoryService.FindOneByOwnerAndRepo(owner, repoName) if err != nil { if response != nil && response.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("repository %s is either non-existent, private, or has not been indexed yet", repoURL) diff --git a/go.mod b/go.mod index bc20216..a28aa98 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( github.com/cli/browser v1.3.0 github.com/go-git/go-git/v5 v5.12.0 github.com/jpmcb/gopherlogs v0.2.0 - github.com/open-sauced/go-api/client v0.0.0-20240205155059-a3159bc0517e github.com/posthog/posthog-go v1.2.19 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 diff --git a/go.sum b/go.sum index 6f949cf..3adc853 100644 --- a/go.sum +++ b/go.sum @@ -94,8 +94,6 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= -github.com/open-sauced/go-api/client v0.0.0-20240205155059-a3159bc0517e h1:3j5r7ArokAO+u8vhgQPklp5qnGxA+MkXluRYt2qTnik= -github.com/open-sauced/go-api/client v0.0.0-20240205155059-a3159bc0517e/go.mod h1:W/TRuLUqYpMvkmElDUQvQ07xlxhK8TOfpwRh8SCAuNA= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/pkg/api/client.go b/pkg/api/client.go deleted file mode 100644 index b2bdfd6..0000000 --- a/pkg/api/client.go +++ /dev/null @@ -1,34 +0,0 @@ -package api - -import ( - "net/http" - - "github.com/open-sauced/go-api/client" -) - -type Client struct { - // The configured http client for making API requests - HTTPClient *http.Client - - // The API endpoint to use when making requests - // Example: https://api.opensauced.pizza - Endpoint string -} - -// NewClient creates a new OpenSauced API client for making http requests -func NewClient(endpoint string) *Client { - return &Client{ - HTTPClient: &http.Client{}, - Endpoint: endpoint, - } -} - -func NewGoClient(endpoint string) *client.APIClient { - configuration := client.NewConfiguration() - configuration.Servers = client.ServerConfigurations{ - { - URL: endpoint, - }, - } - return client.NewAPIClient(configuration) -} diff --git a/pkg/api/validation.go b/pkg/api/validation.go deleted file mode 100644 index 6236ac5..0000000 --- a/pkg/api/validation.go +++ /dev/null @@ -1,5 +0,0 @@ -package api - -func IsValidRange(period int32) bool { - return period == 7 || period == 30 || period == 90 -} diff --git a/pkg/constants/flags.go b/pkg/constants/flags.go index 7489e8b..304aa6e 100644 --- a/pkg/constants/flags.go +++ b/pkg/constants/flags.go @@ -5,7 +5,7 @@ const ( FlagNameEndpoint = "endpoint" FlagNameFile = "file" FlagNameOutput = "output" - FlagNamePeriod = "period" + FlagNameRange = "range" FlagNameTelemetry = "disable-telemetry" FlagNameWait = "wait" )