Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add aggregator #2

Merged
merged 9 commits into from
Jun 24, 2022
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 218 additions & 0 deletions v5/pivotal/aggregator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
package pivotal

import (
"encoding/json"
"fmt"
"math"
)

const aggregatorURL = "https://www.pivotaltracker.com/services/v5/aggregator"

const requestsPerAggregation = 15 // Number of requests per aggregation.

// AggregatorService is used to wrap the client.
type AggregatorService struct {
client *Client
}

// Aggregation is the data object for an aggregation.
// It is used for storing the urls of the requests and the response.
type Aggregation struct {
// Requests are the GET requests that are used by the aggregator.
requests []string

// Storing the response json along with the urls.
aggregatedResponse map[string]interface{}

// The service of the aggregation
service *AggregatorService
}

// GetBuilder returns a builder for building an aggregation.
func (a *AggregatorService) GetBuilder() *Aggregation {
aggregation := Aggregation{
aggregatedResponse: make(map[string]interface{}),
service: a,
}
return &aggregation
}

func newAggregatorService(client *Client) *AggregatorService {
return &AggregatorService{client}
}

// Story adds a story request to the aggregation.
func (a *Aggregation) Story(projectID, storyID int) *Aggregation {
a.requests = append(a.requests, buildStoryURL(projectID, storyID))
return a
}

// StoryUsingStoryID adds a story request using only using the story ID.
func (a *Aggregation) StoryUsingStoryID(storyID int) *Aggregation {
a.requests = append(a.requests, buildStoryURLOnlyUsingStoryID(storyID))
return a
}

// Stories adds a list of story requests to an aggregation.
func (a *Aggregation) Stories(projectID int, storyIDs []int) *Aggregation {
for _, storyID := range storyIDs {
a.Story(projectID, storyID)
}
return a
}

// CommentsOfStory adds a request for getting the comments of a story.
func (a *Aggregation) CommentsOfStory(projectID, storyID int) {
a.requests = append(a.requests, buildCommentsURL(projectID, storyID))
}

// CommentsOfStories adds multiple requests for getting the comments of a list of stories.
func (a *Aggregation) CommentsOfStories(projectID int, storyIDs []int) *Aggregation {
for _, storyID := range storyIDs {
a.CommentsOfStory(projectID, storyID)
}
return a
}

// ReviewsOfStory adds multiple requests for getting the reviews of a story.
func (a *Aggregation) ReviewsOfStory(projectID, storyID int) *Aggregation {
a.requests = append(a.requests, buildReviewsURL(projectID, storyID))
return a
}

// ReviewsOfStories adds multiple requests for getting the reviews of multiple stories.
func (a *Aggregation) ReviewsOfStories(projectID int, storyIDs []int) *Aggregation {
for _, storyID := range storyIDs {
a.ReviewsOfStory(projectID, storyID)
}
return a
}

func buildStoryURLOnlyUsingStoryID(storyID int) string {
return fmt.Sprintf("/services/v5/stories/%d", storyID)
}

func buildStoryURL(projectID, storyID int) string {
return fmt.Sprintf("/services/v5/projects/%d/stories/%d", projectID, storyID)
}

func buildCommentsURL(projectID, storyID int) string {
return fmt.Sprintf("/services/v5/projects/%d/stories/%d/comments", projectID, storyID)
}

func buildReviewsURL(projectID, storyID int) string {
return fmt.Sprintf("/services/v5/projects/%d/stories/%d/reviews?fields=id,story_id,review_type,review_type_id,reviewer_id,status,created_at,updated_at,kind", projectID, storyID)
}

func maxPagesPagination(total int, perPage int) int {
pagesNumber := math.Ceil(float64(total) / float64(perPage))
return int(pagesNumber)
}

func paginate(reqs []string, currentPage, total int, perPage int) []string {
firstEntry := (currentPage - 1) * perPage
lastEntry := firstEntry + perPage

if lastEntry > total {
lastEntry = total
}

return reqs[firstEntry:lastEntry]
}

// Send completes the aggregation and sends it to Pivotal Tracker.
func (a *Aggregation) Send() (*Aggregation, error) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my previous comments were lost on a rebase or something, but this should return *http.Response as the second return value to match the rest of the API in this module.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok. Maybe just ignore this comment then. We don't need it ourselves. If upstream ever shows a willingness to merge this, we can deal with the concern then.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I send bulks of 15 requests in Send. So, there are multiple http.Responses.

In my next commit, I've changed Send to only send the next bulk in the aggregation and return the corresponding http.Response. And then I've added another SendAll method, which sends all the bulks in one go. SendAll returns the http.Responses in a slice. Do you think that is fine?

N := len(a.requests)
max := maxPagesPagination(N, requestsPerAggregation)
for currentPage := 1; currentPage <= max; currentPage++ {
currentReqs := paginate(a.requests, currentPage, N, requestsPerAggregation)

aggregatedResponse := make(map[string]interface{})

req, err := a.service.client.NewRequest("POST", aggregatorURL, currentReqs)
if err != nil {
return nil, err
}

_, err = a.service.client.Do(req, &aggregatedResponse)
if err != nil {
return nil, err
}

// Appending the response body into the current aggregation for getters.
for url, response := range aggregatedResponse {
a.aggregatedResponse[url] = response
}
}

return a, nil
}

// GetStoryOnlyUsingStoryID returns the story using only the story ID.
func (a *Aggregation) GetStoryOnlyUsingStoryID(storyID int) (*Story, error) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetStoryByID might be a bit less wordy and seems clear enough. If you update this, also update the private function for building the URL.

u := buildStoryURLOnlyUsingStoryID(storyID)
response, ok := a.aggregatedResponse[u]
if !ok {
return nil, fmt.Errorf("Story %d doesn't exist.", storyID)
}
byteData, _ := json.Marshal(response)

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should return an error if this fails. Same with the other json.Marshal calls.

I wonder if it would make sense to wrap this in a helper function, e.g.:

func mapTo(from interface{}, to interface{}) error {
    b, err := json.Marshal(from)
    if err != nil {
         return err
    }
    return json.Unmarshal(b, &to)
}

This should reduce the repeated code and in the future might make it easier to replace this with something more efficient, e.g., https://github.com/mitchellh/mapstructure.

// Handling get story requests if it isn't comments/reviews.
var story Story
err := json.Unmarshal(byteData, &story)
if err != nil {
return nil, err
}
return &story, nil
}

// GetStory returns the story using both the project ID and the story ID.
func (a *Aggregation) GetStory(projectID, storyID int) (*Story, error) {
u := buildStoryURL(projectID, storyID)
response, ok := a.aggregatedResponse[u]
if !ok {
return nil, fmt.Errorf("Story %d doesn't exist for project %d.", storyID, projectID)
}
byteData, _ := json.Marshal(response)

// Handling get story requests if it isn't comments/reviews.
var story Story
err := json.Unmarshal(byteData, &story)
if err != nil {
return nil, err
}
return &story, nil
}

// GetComments returns the comments of a story.
func (a *Aggregation) GetComments(projectID, storyID int) ([]Comment, error) {
u := buildCommentsURL(projectID, storyID)
response, ok := a.aggregatedResponse[u]
if !ok {
return nil, fmt.Errorf("Story %d comments don't exist for project %d.", storyID, projectID)
}
byteData, _ := json.Marshal(response)

var comments []Comment
err := json.Unmarshal(byteData, &comments)
if err != nil {
return nil, err
}
return comments, nil
}

// GetReviews returns the reviews of a story.
func (a *Aggregation) GetReviews(projectID, storyID int) ([]Review, error) {
u := buildReviewsURL(projectID, storyID)
response, ok := a.aggregatedResponse[u]
if !ok {
return nil, fmt.Errorf("Story %d reviews don't exist for project %d.", storyID, projectID)
}
byteData, _ := json.Marshal(response)
var reviews []Review
err := json.Unmarshal(byteData, &reviews)
if err != nil {
return nil, err
}
return reviews, nil
}
4 changes: 4 additions & 0 deletions v5/pivotal/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ type Client struct {

// Epic Service
Epic *EpicService

// Aggregator Service
Aggregator *AggregatorService
}

// NewClient takes a Pivotal Tracker API Token (created from the project settings) and
Expand All @@ -76,6 +79,7 @@ func NewClient(apiToken string) *Client {
client.Iterations = newIterationService(client)
client.Activity = newActivitiesService(client)
client.Epic = newEpicService(client)
client.Aggregator = newAggregatorService(client)
return client
}

Expand Down