dmitri.shuralyov.com/service/change/...

Add changes, githubapi.
dmitshur committed 6 years ago commit 391e3d90eb222279a56521627867335e3c409cf2
Collapse all
changes.go
@@ -0,0 +1,47 @@
// Package changes provides a changes service definition.
package changes

import (
	"context"
	"time"

	"github.com/shurcooL/users"
)

// Service defines methods of a change tracking service.
type Service interface {
	// List changes.
	List(ctx context.Context, repo string) ([]Change, error)
	// Count changes.
	Count(ctx context.Context, repo string) (uint64, error)

	// Get a change.
	Get(ctx context.Context, repo string, id uint64) (Change, error)

	// ListComments lists comments for specified change id.
	//ListComments(ctx context.Context, repo string, id uint64, opt *ListOptions) ([]Comment, error)
	// ListEvents lists events for specified change id.
	//ListEvents(ctx context.Context, repo string, id uint64, opt *ListOptions) ([]Event, error)
}

// Change represents a change in a repository.
type Change struct {
	ID        uint64
	State     State
	Title     string
	Author    users.User
	CreatedAt time.Time
	Replies   int // Number of replies to this change (not counting the mandatory change description comment).
}

// State represents the change state.
type State string

const (
	// OpenState is when a change is open.
	OpenState State = "open"
	// ClosedState is when a change is closed.
	ClosedState State = "closed"
	// MergedState is when a change is merged.
	MergedState State = "merged"
)
githubapi/githubapi.go
@@ -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)
}