@@ -257,38 +257,34 @@ func (s service) ListTimeline(ctx context.Context, rs string, id uint64, opt *ch repo, err := ghRepoSpec(rs) if err != nil { // TODO: Map to 400 Bad Request HTTP error. return nil, err } type comment struct { // Comment fields. Author *githubV4Actor PublishedAt githubv4.DateTime LastEditedAt *githubv4.DateTime Editor *githubV4Actor Body string ReactionGroups reactionGroups ViewerCanUpdate bool } type event struct { // Common fields for all events. Actor *githubV4Actor CreatedAt githubv4.DateTime } var q struct { Repository struct { PullRequest struct { Author *githubV4Actor PublishedAt githubv4.DateTime LastEditedAt *githubv4.DateTime Editor *githubV4Actor Body githubv4.String ReactionGroups reactionGroups ViewerCanUpdate bool comment `graphql:"...@include(if:$firstPage)"` // Fetch the PR description only on first page. Timeline struct { Nodes []struct { Typename string `graphql:"__typename"` IssueComment struct { DatabaseID uint64 Author *githubV4Actor PublishedAt githubv4.DateTime LastEditedAt *githubv4.DateTime Editor *githubV4Actor Body string ReactionGroups reactionGroups ViewerCanUpdate bool DatabaseID uint64 comment } `graphql:"...on IssueComment"` ClosedEvent struct { event Closer struct { Typename string `graphql:"__typename"` @@ -360,11 +356,15 @@ func (s service) ListTimeline(ctx context.Context, rs string, id uint64, opt *ch // TODO: Wait for GitHub to add support. //CommentDeletedEvent struct { // event //} `graphql:"...on CommentDeletedEvent"` } } `graphql:"timeline(first:100)"` // TODO: Pagination... PageInfo struct { EndCursor githubv4.String HasNextPage githubv4.Boolean } } `graphql:"timeline(first:100,after:$timelineCursor)"` // Need to use PullRequest.Reviews rather than PullRequest.Timeline.PullRequestReview, // because the latter is missing single-inline-reply reviews (as of 2018-02-08). Reviews struct { Nodes []struct { @@ -384,204 +384,214 @@ func (s service) ListTimeline(ctx context.Context, rs string, id uint64, opt *ch Body string ReactionGroups reactionGroups } } `graphql:"comments(first:100)"` // TODO: Pagination... Figure out how to make pagination across 2 resource types work... } } `graphql:"reviews(first:100)"` // TODO: Pagination... Figure out how to make pagination across 2 resource types work... } `graphql:"reviews(first:100)@include(if:$firstPage)"` // TODO: Pagination... Figure out how to make pagination across 2 resource types work... } `graphql:"pullRequest(number:$prNumber)"` } `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"` Viewer githubV4User } variables := map[string]interface{}{ "repositoryOwner": githubv4.String(repo.Owner), "repositoryName": githubv4.String(repo.Repo), "prNumber": githubv4.Int(id), "firstPage": githubv4.Boolean(true), "timelineCursor": (*githubv4.String)(nil), } err = s.clV4.Query(ctx, &q, variables) if err != nil { return nil, err } var timeline []interface{} { pr := q.Repository.PullRequest var edited *change.Edited if pr.LastEditedAt != nil { edited = &change.Edited{ By: ghActor(pr.Editor), At: pr.LastEditedAt.Time, } } timeline = append(timeline, change.Comment{ ID: prDescriptionCommentID, User: ghActor(pr.Author), CreatedAt: pr.PublishedAt.Time, Edited: edited, Body: string(pr.Body), Reactions: ghReactions(pr.ReactionGroups, ghUser(&q.Viewer)), Editable: pr.ViewerCanUpdate, }) } for _, node := range q.Repository.PullRequest.Timeline.Nodes { if node.Typename != "IssueComment" { continue var timeline []interface{} // Of type change.Comment, change.Review, change.TimelineItem. for { err := s.clV4.Query(ctx, &q, variables) if err != nil { return nil, err } comment := node.IssueComment var edited *change.Edited if comment.LastEditedAt != nil { edited = &change.Edited{ By: ghActor(comment.Editor), At: comment.LastEditedAt.Time, if variables["firstPage"].(githubv4.Boolean) { pr := q.Repository.PullRequest.comment // PR description comment. var edited *change.Edited if pr.LastEditedAt != nil { edited = &change.Edited{ By: ghActor(pr.Editor), At: pr.LastEditedAt.Time, } } timeline = append(timeline, change.Comment{ ID: prDescriptionCommentID, User: ghActor(pr.Author), CreatedAt: pr.PublishedAt.Time, Edited: edited, Body: pr.Body, Reactions: ghReactions(pr.ReactionGroups, ghUser(&q.Viewer)), Editable: pr.ViewerCanUpdate, }) } timeline = append(timeline, change.Comment{ ID: fmt.Sprintf("c%d", comment.DatabaseID), User: ghActor(comment.Author), CreatedAt: comment.PublishedAt.Time, Edited: edited, Body: comment.Body, Reactions: ghReactions(comment.ReactionGroups, ghUser(&q.Viewer)), Editable: comment.ViewerCanUpdate, }) } for _, review := range q.Repository.PullRequest.Reviews.Nodes { state, ok := ghPRReviewState(review.State) if !ok { continue } var edited *change.Edited if review.LastEditedAt != nil { edited = &change.Edited{ By: ghActor(review.Editor), At: review.LastEditedAt.Time, for _, node := range q.Repository.PullRequest.Timeline.Nodes { if node.Typename != "IssueComment" { continue } } var cs []change.InlineComment for _, comment := range review.Comments.Nodes { cs = append(cs, change.InlineComment{ ID: fmt.Sprintf("rc%d", comment.DatabaseID), File: comment.Path, Line: comment.OriginalPosition, // TODO: This isn't line in file, it's line *in the diff*. Take it into account, compute real line, etc. comment := node.IssueComment var edited *change.Edited if comment.LastEditedAt != nil { edited = &change.Edited{ By: ghActor(comment.Editor), At: comment.LastEditedAt.Time, } } timeline = append(timeline, change.Comment{ ID: fmt.Sprintf("c%d", comment.DatabaseID), User: ghActor(comment.Author), CreatedAt: comment.PublishedAt.Time, Edited: edited, Body: comment.Body, Reactions: ghReactions(comment.ReactionGroups, ghUser(&q.Viewer)), Editable: comment.ViewerCanUpdate, }) } sort.Slice(cs, func(i, j int) bool { if cs[i].File == cs[j].File { return cs[i].Line < cs[j].Line if variables["firstPage"].(githubv4.Boolean) { for _, review := range q.Repository.PullRequest.Reviews.Nodes { state, ok := ghPRReviewState(review.State) if !ok { continue } var edited *change.Edited if review.LastEditedAt != nil { edited = &change.Edited{ By: ghActor(review.Editor), At: review.LastEditedAt.Time, } } var cs []change.InlineComment for _, comment := range review.Comments.Nodes { cs = append(cs, change.InlineComment{ ID: fmt.Sprintf("rc%d", comment.DatabaseID), File: comment.Path, Line: comment.OriginalPosition, // TODO: This isn't line in file, it's line *in the diff*. Take it into account, compute real line, etc. Body: comment.Body, Reactions: ghReactions(comment.ReactionGroups, ghUser(&q.Viewer)), }) } sort.Slice(cs, func(i, j int) bool { if cs[i].File == cs[j].File { return cs[i].Line < cs[j].Line } return cs[i].File < cs[j].File }) timeline = append(timeline, change.Review{ ID: fmt.Sprintf("r%d", review.DatabaseID), User: ghActor(review.Author), CreatedAt: review.PublishedAt.Time, Edited: edited, State: state, Body: review.Body, Editable: review.ViewerCanUpdate, Comments: cs, }) } return cs[i].File < cs[j].File }) timeline = append(timeline, change.Review{ ID: fmt.Sprintf("r%d", review.DatabaseID), User: ghActor(review.Author), CreatedAt: review.PublishedAt.Time, Edited: edited, State: state, Body: review.Body, Editable: review.ViewerCanUpdate, Comments: cs, }) } for _, event := range q.Repository.PullRequest.Timeline.Nodes { e := change.TimelineItem{ //ID: 0, // TODO. } switch event.Typename { case "ClosedEvent": e.Actor = ghActor(event.ClosedEvent.Actor) e.CreatedAt = event.ClosedEvent.CreatedAt.Time switch event.ClosedEvent.Closer.Typename { case "PullRequest": pr := event.ClosedEvent.Closer.PullRequest e.Payload = change.ClosedEvent{ Closer: change.Change{ State: ghPRState(pr.State), Title: pr.Title, for _, event := range q.Repository.PullRequest.Timeline.Nodes { e := change.TimelineItem{ //ID: 0, // TODO. } switch event.Typename { case "ClosedEvent": e.Actor = ghActor(event.ClosedEvent.Actor) e.CreatedAt = event.ClosedEvent.CreatedAt.Time switch event.ClosedEvent.Closer.Typename { case "PullRequest": pr := event.ClosedEvent.Closer.PullRequest e.Payload = change.ClosedEvent{ Closer: change.Change{ State: ghPRState(pr.State), Title: pr.Title, }, CloserHTMLURL: s.rtr.PullRequestURL(ctx, pr.Repository.Owner.Login, pr.Repository.Name, pr.Number), } case "Commit": c := event.ClosedEvent.Closer.Commit e.Payload = change.ClosedEvent{ Closer: change.Commit{ SHA: c.OID, Message: c.Message, Author: users.User{AvatarURL: c.Author.AvatarURL}, }, CloserHTMLURL: c.URL, } default: e.Payload = change.ClosedEvent{} } case "ReopenedEvent": e.Actor = ghActor(event.ReopenedEvent.Actor) e.CreatedAt = event.ReopenedEvent.CreatedAt.Time e.Payload = change.ReopenedEvent{} case "RenamedTitleEvent": e.Actor = ghActor(event.RenamedTitleEvent.Actor) e.CreatedAt = event.RenamedTitleEvent.CreatedAt.Time e.Payload = change.RenamedEvent{ From: event.RenamedTitleEvent.PreviousTitle, To: event.RenamedTitleEvent.CurrentTitle, } case "LabeledEvent": e.Actor = ghActor(event.LabeledEvent.Actor) e.CreatedAt = event.LabeledEvent.CreatedAt.Time e.Payload = change.LabeledEvent{ Label: issues.Label{ Name: event.LabeledEvent.Label.Name, Color: ghColor(event.LabeledEvent.Label.Color), }, CloserHTMLURL: s.rtr.PullRequestURL(ctx, pr.Repository.Owner.Login, pr.Repository.Name, pr.Number), } case "Commit": c := event.ClosedEvent.Closer.Commit e.Payload = change.ClosedEvent{ Closer: change.Commit{ SHA: c.OID, Message: c.Message, Author: users.User{AvatarURL: c.Author.AvatarURL}, case "UnlabeledEvent": e.Actor = ghActor(event.UnlabeledEvent.Actor) e.CreatedAt = event.UnlabeledEvent.CreatedAt.Time e.Payload = change.UnlabeledEvent{ Label: issues.Label{ Name: event.UnlabeledEvent.Label.Name, Color: ghColor(event.UnlabeledEvent.Label.Color), }, CloserHTMLURL: c.URL, } case "ReviewRequestedEvent": e.Actor = ghActor(event.ReviewRequestedEvent.Actor) e.CreatedAt = event.ReviewRequestedEvent.CreatedAt.Time e.Payload = change.ReviewRequestedEvent{ RequestedReviewer: ghUser(event.ReviewRequestedEvent.RequestedReviewer.User), } case "ReviewRequestRemovedEvent": e.Actor = ghActor(event.ReviewRequestRemovedEvent.Actor) e.CreatedAt = event.ReviewRequestRemovedEvent.CreatedAt.Time e.Payload = change.ReviewRequestRemovedEvent{ RequestedReviewer: ghUser(event.ReviewRequestRemovedEvent.RequestedReviewer.User), } case "MergedEvent": e.Actor = ghActor(event.MergedEvent.Actor) e.CreatedAt = event.MergedEvent.CreatedAt.Time e.Payload = change.MergedEvent{ CommitID: event.MergedEvent.Commit.OID, CommitHTMLURL: event.MergedEvent.Commit.URL, RefName: event.MergedEvent.MergeRefName, } case "HeadRefDeletedEvent": e.Actor = ghActor(event.HeadRefDeletedEvent.Actor) e.CreatedAt = event.HeadRefDeletedEvent.CreatedAt.Time e.Payload = change.DeletedEvent{ Type: "branch", Name: event.HeadRefDeletedEvent.HeadRefName, } // TODO: Wait for GitHub to add support. //case "CommentDeletedEvent": // e.Actor = ghActor(event.CommentDeletedEvent.Actor) // e.CreatedAt = event.CommentDeletedEvent.CreatedAt.Time default: e.Payload = change.ClosedEvent{} } case "ReopenedEvent": e.Actor = ghActor(event.ReopenedEvent.Actor) e.CreatedAt = event.ReopenedEvent.CreatedAt.Time e.Payload = change.ReopenedEvent{} case "RenamedTitleEvent": e.Actor = ghActor(event.RenamedTitleEvent.Actor) e.CreatedAt = event.RenamedTitleEvent.CreatedAt.Time e.Payload = change.RenamedEvent{ From: event.RenamedTitleEvent.PreviousTitle, To: event.RenamedTitleEvent.CurrentTitle, } case "LabeledEvent": e.Actor = ghActor(event.LabeledEvent.Actor) e.CreatedAt = event.LabeledEvent.CreatedAt.Time e.Payload = change.LabeledEvent{ Label: issues.Label{ Name: event.LabeledEvent.Label.Name, Color: ghColor(event.LabeledEvent.Label.Color), }, } case "UnlabeledEvent": e.Actor = ghActor(event.UnlabeledEvent.Actor) e.CreatedAt = event.UnlabeledEvent.CreatedAt.Time e.Payload = change.UnlabeledEvent{ Label: issues.Label{ Name: event.UnlabeledEvent.Label.Name, Color: ghColor(event.UnlabeledEvent.Label.Color), }, } case "ReviewRequestedEvent": e.Actor = ghActor(event.ReviewRequestedEvent.Actor) e.CreatedAt = event.ReviewRequestedEvent.CreatedAt.Time e.Payload = change.ReviewRequestedEvent{ RequestedReviewer: ghUser(event.ReviewRequestedEvent.RequestedReviewer.User), } case "ReviewRequestRemovedEvent": e.Actor = ghActor(event.ReviewRequestRemovedEvent.Actor) e.CreatedAt = event.ReviewRequestRemovedEvent.CreatedAt.Time e.Payload = change.ReviewRequestRemovedEvent{ RequestedReviewer: ghUser(event.ReviewRequestRemovedEvent.RequestedReviewer.User), continue } case "MergedEvent": e.Actor = ghActor(event.MergedEvent.Actor) e.CreatedAt = event.MergedEvent.CreatedAt.Time e.Payload = change.MergedEvent{ CommitID: event.MergedEvent.Commit.OID, CommitHTMLURL: event.MergedEvent.Commit.URL, RefName: event.MergedEvent.MergeRefName, } case "HeadRefDeletedEvent": e.Actor = ghActor(event.HeadRefDeletedEvent.Actor) e.CreatedAt = event.HeadRefDeletedEvent.CreatedAt.Time e.Payload = change.DeletedEvent{ Type: "branch", Name: event.HeadRefDeletedEvent.HeadRefName, } // TODO: Wait for GitHub to add support. //case "CommentDeletedEvent": // e.Actor = ghActor(event.CommentDeletedEvent.Actor) // e.CreatedAt = event.CommentDeletedEvent.CreatedAt.Time default: continue timeline = append(timeline, e) } if !q.Repository.PullRequest.Timeline.PageInfo.HasNextPage { break } timeline = append(timeline, e) variables["firstPage"] = githubv4.Boolean(false) variables["timelineCursor"] = githubv4.NewString(q.Repository.PullRequest.Timeline.PageInfo.EndCursor) } // We can't just delegate pagination to GitHub because our timeline items don't match up 1:1, // we want to skip Commit in the timeline, etc. // We can't just delegate pagination to GitHub because our timeline items may not match up 1:1, // e.g., we want to skip Commit in the timeline, etc. (At least for now; may reconsider later.) if opt != nil { start := opt.Start if start > len(timeline) { start = len(timeline) }