@@ -0,0 +1,656 @@
// Package githubapi implements a read-only issues.Service using
// using GitHub GraphQL API v4 clients that serves PRs.
package githubapi
import (
"context"
"fmt"
"log"
"strings"
"dmitri.shuralyov.com/changes"
"github.com/google/go-github/github"
"github.com/shurcooL/githubql"
"github.com/shurcooL/issues"
"github.com/shurcooL/notifications"
"github.com/shurcooL/reactions"
"github.com/shurcooL/users"
)
// NewService creates a GitHub-backed issues.Service using given GitHub clients.
// It uses notifications service, if not nil. At this time it infers the current user
// from the client (its authentication info), and cannot be used to serve multiple users.
func NewService(clientV3 *github.Client, clientV4 *githubql.Client, notifications notifications.ExternalService, users users.Service) issues.Service {
s := service{
clV3: clientV3,
clV4: clientV4,
notifications: notifications,
users: users,
}
s.currentUser, s.currentUserErr = s.users.GetAuthenticated(context.TODO())
return s
}
type service struct {
clV3 *github.Client // GitHub REST API v3 client.
clV4 *githubql.Client // GitHub GraphQL API v4 client.
// notifications may be nil if there's no notifications service.
notifications notifications.ExternalService
users users.Service
currentUser users.User
currentUserErr error
}
// We use 0 as a special ID for the comment that is the issue description. This comment is edited differently.
const issueDescriptionCommentID uint64 = 0
func (s service) List(ctx context.Context, rs issues.RepoSpec, opt issues.IssueListOptions) ([]issues.Issue, error) {
repo, err := ghRepoSpec(rs)
if err != nil {
// TODO: Map to 400 Bad Request HTTP error.
return nil, err
}
var states []githubql.PullRequestState
switch opt.State {
case issues.StateFilter(changes.OpenState):
states = []githubql.PullRequestState{githubql.PullRequestStateOpen}
case issues.StateFilter(changes.ClosedState):
states = []githubql.PullRequestState{githubql.PullRequestStateClosed}
case issues.StateFilter(changes.MergedState):
states = []githubql.PullRequestState{githubql.PullRequestStateMerged}
case issues.AllStates:
states = nil // No states to filter the PRs by.
default:
// TODO: Map to 400 Bad Request HTTP error.
return nil, fmt.Errorf("opt.State has unsupported value %q", opt.State)
}
var q struct {
Repository struct {
PullRequests struct {
Nodes []struct {
Number uint64
State githubql.PullRequestState
Title string
Labels struct {
Nodes []struct {
Name string
Color string
}
} `graphql:"labels(first:100)"`
Author githubqlActor
CreatedAt githubql.DateTime
Comments struct {
TotalCount int
}
}
} `graphql:"pullRequests(first:30,orderBy:{field:CREATED_AT,direction:DESC},states:$prStates)"`
} `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"`
}
variables := map[string]interface{}{
"repositoryOwner": githubql.String(repo.Owner),
"repositoryName": githubql.String(repo.Repo),
"prStates": states,
}
err = s.clV4.Query(ctx, &q, variables)
if err != nil {
return nil, err
}
var is []issues.Issue
for _, issue := range q.Repository.PullRequests.Nodes {
var labels []issues.Label
for _, l := range issue.Labels.Nodes {
labels = append(labels, issues.Label{
Name: l.Name,
Color: ghColor(l.Color),
})
}
is = append(is, issues.Issue{
ID: issue.Number,
State: issues.State(ghPRState(issue.State)),
Title: issue.Title,
Labels: labels,
Comment: issues.Comment{
User: ghActor(issue.Author),
CreatedAt: issue.CreatedAt.Time,
},
Replies: issue.Comments.TotalCount,
})
}
return is, nil
}
func (s service) Count(ctx context.Context, rs issues.RepoSpec, opt issues.IssueListOptions) (uint64, error) {
repo, err := ghRepoSpec(rs)
if err != nil {
// TODO: Map to 400 Bad Request HTTP error.
return 0, err
}
var states []githubql.PullRequestState
switch opt.State {
case issues.StateFilter(changes.OpenState):
states = []githubql.PullRequestState{githubql.PullRequestStateOpen}
case issues.StateFilter(changes.ClosedState):
states = []githubql.PullRequestState{githubql.PullRequestStateClosed}
case issues.StateFilter(changes.MergedState):
states = []githubql.PullRequestState{githubql.PullRequestStateMerged}
case issues.AllStates:
states = nil // No states to filter the PRs by.
default:
// TODO: Map to 400 Bad Request HTTP error.
return 0, fmt.Errorf("opt.State has unsupported value %q", opt.State)
}
var q struct {
Repository struct {
PullRequests struct {
TotalCount uint64
} `graphql:"pullRequests(states:$prStates)"`
} `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"`
}
variables := map[string]interface{}{
"repositoryOwner": githubql.String(repo.Owner),
"repositoryName": githubql.String(repo.Repo),
"prStates": states,
}
err = s.clV4.Query(ctx, &q, variables)
return q.Repository.PullRequests.TotalCount, err
}
func (s service) Get(ctx context.Context, rs issues.RepoSpec, id uint64) (issues.Issue, error) {
repo, err := ghRepoSpec(rs)
if err != nil {
// TODO: Map to 400 Bad Request HTTP error.
return issues.Issue{}, err
}
var q struct {
Repository struct {
PullRequest struct {
Number uint64
State githubql.PullRequestState
Title string
Author githubqlActor
CreatedAt githubql.DateTime
ViewerCanUpdate githubql.Boolean
} `graphql:"pullRequest(number:$prNumber)"`
} `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"`
}
variables := map[string]interface{}{
"repositoryOwner": githubql.String(repo.Owner),
"repositoryName": githubql.String(repo.Repo),
"prNumber": githubql.Int(id),
}
err = s.clV4.Query(ctx, &q, variables)
if err != nil {
return issues.Issue{}, err
}
if s.currentUser.ID != 0 {
// Mark as read.
err = s.markRead(ctx, rs, id)
if err != nil {
log.Println("service.Get: failed to markRead:", err)
}
}
// TODO: Eliminate comment body properties from issues.Issue. It's missing increasingly more fields, like Edited, etc.
pr := q.Repository.PullRequest
return issues.Issue{
ID: pr.Number,
State: issues.State(ghPRState(pr.State)),
Title: pr.Title,
Comment: issues.Comment{
User: ghActor(pr.Author),
CreatedAt: pr.CreatedAt.Time,
Editable: bool(pr.ViewerCanUpdate),
},
}, nil
}
func (s service) ListComments(ctx context.Context, rs issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Comment, error) {
// TODO: Respect opt.Start and opt.Length, if given.
repo, err := ghRepoSpec(rs)
if err != nil {
return nil, err
}
var comments []issues.Comment
var q struct {
Repository struct {
PullRequest struct {
Author githubqlActor
PublishedAt githubql.DateTime
LastEditedAt *githubql.DateTime
Editor *githubqlActor
Body githubql.String
ReactionGroups reactionGroups
ViewerCanUpdate githubql.Boolean
// TODO: Combine with first page of Comments...
} `graphql:"pullRequest(number:$prNumber)"`
} `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"`
}
variables := map[string]interface{}{
"repositoryOwner": githubql.String(repo.Owner),
"repositoryName": githubql.String(repo.Repo),
"prNumber": githubql.Int(id),
}
err = s.clV4.Query(ctx, &q, variables)
if err != nil {
return comments, err
}
pr := q.Repository.PullRequest
reactions, err := s.reactions(pr.ReactionGroups)
if err != nil {
return comments, err
}
var edited *issues.Edited
if pr.LastEditedAt != nil {
edited = &issues.Edited{
By: ghActor(*pr.Editor),
At: pr.LastEditedAt.Time,
}
}
comments = append(comments, issues.Comment{
ID: issueDescriptionCommentID,
User: ghActor(pr.Author),
CreatedAt: pr.PublishedAt.Time,
Edited: edited,
Body: string(pr.Body),
Reactions: reactions,
Editable: bool(pr.ViewerCanUpdate),
})
{
var q struct {
Repository struct {
PullRequest struct {
Comments struct {
Nodes []struct {
DatabaseID githubql.Int
Author githubqlActor
PublishedAt githubql.DateTime
LastEditedAt *githubql.DateTime
Editor *githubqlActor
Body githubql.String
ReactionGroups reactionGroups
ViewerCanUpdate githubql.Boolean
}
PageInfo struct {
EndCursor githubql.String
HasNextPage githubql.Boolean
}
} `graphql:"comments(first:1,after:$commentsCursor)"` // TODO: Increase page size too 100 after done testing.
} `graphql:"pullRequest(number:$prNumber)"`
} `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"`
}
variables := map[string]interface{}{
"repositoryOwner": githubql.String(repo.Owner),
"repositoryName": githubql.String(repo.Repo),
"prNumber": githubql.Int(id),
"commentsCursor": (*githubql.String)(nil),
}
for {
err := s.clV4.Query(ctx, &q, variables)
if err != nil {
return comments, err
}
for _, comment := range q.Repository.PullRequest.Comments.Nodes {
reactions, err := s.reactions(comment.ReactionGroups)
if err != nil {
return comments, err
}
var edited *issues.Edited
if comment.LastEditedAt != nil {
edited = &issues.Edited{
By: ghActor(*comment.Editor),
At: comment.LastEditedAt.Time,
}
}
comments = append(comments, issues.Comment{
ID: uint64(comment.DatabaseID),
User: ghActor(comment.Author),
CreatedAt: comment.PublishedAt.Time,
Edited: edited,
Body: string(comment.Body),
Reactions: reactions,
Editable: bool(comment.ViewerCanUpdate),
})
}
if !q.Repository.PullRequest.Comments.PageInfo.HasNextPage {
break
}
variables["commentsCursor"] = githubql.NewString(q.Repository.PullRequest.Comments.PageInfo.EndCursor)
}
}
return comments, nil
}
func (s service) ListEvents(ctx context.Context, rs issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Event, error) {
repo, err := ghRepoSpec(rs)
if err != nil {
// TODO: Map to 400 Bad Request HTTP error.
return nil, err
}
type event struct { // Common fields for all events.
Actor githubqlActor
CreatedAt githubql.DateTime
}
var q struct {
Repository struct {
PullRequest struct {
Timeline struct {
Nodes []struct {
Typename string `graphql:"__typename"`
ClosedEvent struct {
event
} `graphql:"...on ClosedEvent"`
ReopenedEvent struct {
event
} `graphql:"...on ReopenedEvent"`
RenamedTitleEvent struct {
event
CurrentTitle string
PreviousTitle string
} `graphql:"...on RenamedTitleEvent"`
LabeledEvent struct {
event
Label struct {
Name string
Color string
}
} `graphql:"...on LabeledEvent"`
UnlabeledEvent struct {
event
Label struct {
Name string
Color string
}
} `graphql:"...on UnlabeledEvent"`
}
} `graphql:"timeline(first:100)"` // TODO: Paginate?
} `graphql:"pullRequest(number:$prNumber)"`
} `graphql:"repository(owner:$repositoryOwner,name:$repositoryName)"`
}
variables := map[string]interface{}{
"repositoryOwner": githubql.String(repo.Owner),
"repositoryName": githubql.String(repo.Repo),
"prNumber": githubql.Int(id),
}
err = s.clV4.Query(ctx, &q, variables)
if err != nil {
return nil, err
}
var events []issues.Event
for _, event := range q.Repository.PullRequest.Timeline.Nodes {
et := ghEventType(event.Typename)
if !et.Valid() {
continue
}
e := issues.Event{
//ID: 0, // TODO.
Type: et,
}
switch et {
case issues.Closed:
e.Actor = ghActor(event.ClosedEvent.Actor)
e.CreatedAt = event.ClosedEvent.CreatedAt.Time
case issues.Reopened:
e.Actor = ghActor(event.ReopenedEvent.Actor)
e.CreatedAt = event.ReopenedEvent.CreatedAt.Time
case issues.Renamed:
e.Actor = ghActor(event.RenamedTitleEvent.Actor)
e.CreatedAt = event.RenamedTitleEvent.CreatedAt.Time
e.Rename = &issues.Rename{
From: event.RenamedTitleEvent.PreviousTitle,
To: event.RenamedTitleEvent.CurrentTitle,
}
case issues.Labeled:
e.Actor = ghActor(event.LabeledEvent.Actor)
e.CreatedAt = event.LabeledEvent.CreatedAt.Time
e.Label = &issues.Label{
Name: event.LabeledEvent.Label.Name,
Color: ghColor(event.LabeledEvent.Label.Color),
}
case issues.Unlabeled:
e.Actor = ghActor(event.UnlabeledEvent.Actor)
e.CreatedAt = event.UnlabeledEvent.CreatedAt.Time
e.Label = &issues.Label{
Name: event.UnlabeledEvent.Label.Name,
Color: ghColor(event.UnlabeledEvent.Label.Color),
}
default:
continue
}
events = append(events, e)
}
// We can't just delegate pagination to GitHub because our events don't match up 1:1,
// we want to skip IssueComment in the timeline, etc.
if opt != nil {
start := opt.Start
if start > len(events) {
start = len(events)
}
end := opt.Start + opt.Length
if end > len(events) {
end = len(events)
}
events = events[start:end]
}
return events, nil
}
func (s service) CreateComment(ctx context.Context, rs issues.RepoSpec, id uint64, c issues.Comment) (issues.Comment, error) {
return issues.Comment{}, fmt.Errorf("CreateComment: not implemented")
}
func (s service) Create(ctx context.Context, rs issues.RepoSpec, i issues.Issue) (issues.Issue, error) {
return issues.Issue{}, fmt.Errorf("Create: not implemented")
}
func (s service) Edit(ctx context.Context, rs issues.RepoSpec, id uint64, ir issues.IssueRequest) (issues.Issue, []issues.Event, error) {
return issues.Issue{}, nil, fmt.Errorf("Edit: not implemented")
}
func (s service) EditComment(ctx context.Context, rs issues.RepoSpec, id uint64, cr issues.CommentRequest) (issues.Comment, error) {
return issues.Comment{}, fmt.Errorf("EditComment: not implemented")
}
type repoSpec struct {
Owner string
Repo string
}
func ghRepoSpec(repo issues.RepoSpec) (repoSpec, error) {
// TODO, THINK: Include "github.com/" prefix or not?
// So far I'm leaning towards "yes", because it's more definitive and matches
// local uris that also include host. This way, the host can be checked as part of
// request, rather than kept implicit.
ghOwnerRepo := strings.Split(repo.URI, "/")
if len(ghOwnerRepo) != 3 || ghOwnerRepo[0] != "github.com" || ghOwnerRepo[1] == "" || ghOwnerRepo[2] == "" {
return repoSpec{}, fmt.Errorf(`RepoSpec is not of form "github.com/owner/repo": %q`, repo.URI)
}
return repoSpec{
Owner: ghOwnerRepo[1],
Repo: ghOwnerRepo[2],
}, nil
}
type githubqlActor struct {
User struct {
DatabaseID uint64
} `graphql:"...on User"`
Login string
AvatarURL string `graphql:"avatarUrl(size:96)"`
URL string
}
func ghActor(actor githubqlActor) users.User {
return users.User{
UserSpec: users.UserSpec{
ID: actor.User.DatabaseID,
Domain: "github.com",
},
Login: actor.Login,
AvatarURL: actor.AvatarURL,
HTMLURL: actor.URL,
}
}
func ghUser(user *github.User) users.User {
return users.User{
UserSpec: users.UserSpec{
ID: uint64(*user.ID),
Domain: "github.com",
},
Login: *user.Login,
AvatarURL: *user.AvatarURL,
HTMLURL: *user.HTMLURL,
}
}
// ghPRState converts a GitHub PullRequestState to changes.State.
func ghPRState(state githubql.PullRequestState) changes.State {
switch state {
case githubql.PullRequestStateOpen:
return changes.OpenState
case githubql.PullRequestStateClosed:
return changes.ClosedState
case githubql.PullRequestStateMerged:
return changes.MergedState
default:
panic("unreachable")
}
}
func ghEventType(typename string) issues.EventType {
switch typename {
case "ReopenedEvent": // TODO: Use githubql.IssueTimelineItemReopenedEvent or so.
return issues.Reopened
case "ClosedEvent": // TODO: Use githubql.IssueTimelineItemClosedEvent or so.
return issues.Closed
case "RenamedTitleEvent":
return issues.Renamed
case "LabeledEvent":
return issues.Labeled
case "UnlabeledEvent":
return issues.Unlabeled
case "???": // TODO: Wait for GitHub to add support.
return issues.CommentDeleted
default:
return issues.EventType(typename)
}
}
// ghColor converts a GitHub color hex string like "ff0000"
// into an issues.RGB value.
func ghColor(hex string) issues.RGB {
var c issues.RGB
fmt.Sscanf(hex, "%02x%02x%02x", &c.R, &c.G, &c.B)
return c
}
type reactionGroups []struct {
Content githubql.ReactionContent
Users struct {
Nodes []githubqlActor
TotalCount githubql.Int
} `graphql:"users(first:10)"`
ViewerHasReacted githubql.Boolean
}
// reactions converts []githubql.ReactionGroup to []reactions.Reaction.
func (s service) reactions(rgs reactionGroups) ([]reactions.Reaction, error) {
var rs []reactions.Reaction
for _, rg := range rgs {
if rg.Users.TotalCount == 0 {
continue
}
// Only return the details of first few users and authed user.
var us []users.User
addedAuthedUser := false
for i := 0; i < int(rg.Users.TotalCount); i++ {
if i < len(rg.Users.Nodes) {
actor := ghActor(rg.Users.Nodes[i])
us = append(us, actor)
if s.currentUser.ID != 0 && actor.UserSpec == s.currentUser.UserSpec {
addedAuthedUser = true
}
} else if i == len(rg.Users.Nodes) {
// Add authed user last if they've reacted, but haven't been added already.
if bool(rg.ViewerHasReacted) && !addedAuthedUser {
us = append(us, s.currentUser)
}
} else {
us = append(us, users.User{})
}
}
rs = append(rs, reactions.Reaction{
Reaction: internalizeReaction(rg.Content),
Users: us,
})
}
return rs, nil
}
// internalizeReaction converts githubql.ReactionContent to reactions.EmojiID.
func internalizeReaction(reaction githubql.ReactionContent) reactions.EmojiID {
switch reaction {
case githubql.ReactionContentThumbsUp:
return "+1"
case githubql.ReactionContentThumbsDown:
return "-1"
case githubql.ReactionContentLaugh:
return "smile"
case githubql.ReactionContentHooray:
return "tada"
case githubql.ReactionContentConfused:
return "confused"
case githubql.ReactionContentHeart:
return "heart"
default:
panic("unreachable")
}
}
// externalizeReaction converts reactions.EmojiID to githubql.ReactionContent.
func externalizeReaction(reaction reactions.EmojiID) (githubql.ReactionContent, error) {
switch reaction {
case "+1":
return githubql.ReactionContentThumbsUp, nil
case "-1":
return githubql.ReactionContentThumbsDown, nil
case "smile":
return githubql.ReactionContentLaugh, nil
case "tada":
return githubql.ReactionContentHooray, nil
case "confused":
return githubql.ReactionContentConfused, nil
case "heart":
return githubql.ReactionContentHeart, nil
default:
return "", fmt.Errorf("%q is an unsupported reaction", reaction)
}
}
// threadType is the notifications thread type for this service.
const threadType = "Issue"
// ThreadType returns the notifications thread type for this service.
func (service) ThreadType() string { return threadType }
// markRead marks the specified issue as read for current user.
func (s service) markRead(ctx context.Context, repo issues.RepoSpec, id uint64) error {
if s.notifications == nil {
return nil
}
return s.notifications.MarkRead(ctx, notifications.RepoSpec(repo), threadType, id)
}