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

Implement and use changes.Service interface.

Add GetDiff method to it.
dmitshur committed 2 years ago commit 1725c4c4235d315deaa2317977b3a3906328f339
changes.go
@@ -3,34 +3,38 @@ package changes
 
 import (
 	"context"
 	"time"
 
+	"github.com/shurcooL/issues"
 	"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)
+	List(ctx context.Context, repo string, opt ListOptions) ([]Change, error)
 	// Count changes.
-	Count(ctx context.Context, repo string) (uint64, error)
+	Count(ctx context.Context, repo string, opt ListOptions) (uint64, error)
 
 	// Get a change.
 	Get(ctx context.Context, repo string, id uint64) (Change, error)
+	// Get a change diff.
+	GetDiff(ctx context.Context, repo string, id uint64) ([]byte, error)
 
 	// ListComments lists comments for specified change id.
-	//ListComments(ctx context.Context, repo string, id uint64, opt *ListOptions) ([]Comment, error)
+	ListComments(ctx context.Context, repo string, id uint64, opt *ListCommentsOptions) ([]issues.Comment, error)
 	// ListEvents lists events for specified change id.
-	//ListEvents(ctx context.Context, repo string, id uint64, opt *ListOptions) ([]Event, error)
+	ListEvents(ctx context.Context, repo string, id uint64, opt *ListCommentsOptions) ([]issues.Event, error)
 }
 
 // Change represents a change in a repository.
 type Change struct {
 	ID        uint64
 	State     State
 	Title     string
+	Labels    []issues.Label
 	Author    users.User
 	CreatedAt time.Time
 	Replies   int // Number of replies to this change (not counting the mandatory change description comment).
 }
 
@@ -43,5 +47,27 @@ const (
 	// ClosedState is when a change is closed.
 	ClosedState State = "closed"
 	// MergedState is when a change is merged.
 	MergedState State = "merged"
 )
+
+// ListOptions are options for list operations.
+type ListOptions struct {
+	State StateFilter
+}
+
+// StateFilter is a filter by state.
+type StateFilter State
+
+const (
+	// AllStates is a state filter that includes all issues.
+	AllStates StateFilter = "all"
+)
+
+// ListCommentsOptions controls pagination.
+type ListCommentsOptions struct {
+	// Start is the index of first result to retrieve, zero-indexed.
+	Start int
+
+	// Length is the number of results to include.
+	Length int
+}
gerritapi/gerritapi.go
@@ -1,24 +1,25 @@
-// Package gerritapi implements issues.Service using Gerrit API client.
+// Package gerritapi implements a read-only changes.Service using Gerrit API client.
 package gerritapi
 
 import (
 	"context"
 	"fmt"
 	"os"
 	"sort"
 	"strings"
 	"time"
 
+	"dmitri.shuralyov.com/changes"
 	"github.com/andygrunwald/go-gerrit"
 	"github.com/shurcooL/issues"
 	"github.com/shurcooL/users"
 )
 
 // NewService creates a Gerrit-backed issues.Service using given Gerrit client.
 // client must be non-nil.
-func NewService(client *gerrit.Client) issues.Service {
+func NewService(client *gerrit.Client) changes.Service {
 	s := service{
 		cl:     client,
 		domain: client.BaseURL().Host,
 		//users: users,
 	}
@@ -36,22 +37,24 @@ type service struct {
 
 	//currentUser    users.UserSpec
 	//currentUserErr error
 }
 
-func (s service) List(ctx context.Context, rs issues.RepoSpec, opt issues.IssueListOptions) ([]issues.Issue, error) {
+func (s service) List(ctx context.Context, rs string, opt changes.ListOptions) ([]changes.Change, error) {
 	project := project(rs)
 	var query string
 	switch opt.State {
-	case issues.StateFilter(issues.OpenState):
+	case changes.StateFilter(changes.OpenState):
 		query = fmt.Sprintf("project:%s status:open", project)
-	case issues.StateFilter(issues.ClosedState):
+	case changes.StateFilter(changes.ClosedState):
 		query = fmt.Sprintf("project:%s status:closed", project)
-	case issues.AllStates:
+	case changes.StateFilter(changes.MergedState):
+		query = fmt.Sprintf("project:%s status:merged", project)
+	case changes.AllStates:
 		query = fmt.Sprintf("project:%s", project)
 	}
-	changes, _, err := s.cl.Changes.QueryChanges(&gerrit.QueryChangeOptions{
+	cs, _, err := s.cl.Changes.QueryChanges(&gerrit.QueryChangeOptions{
 		QueryOptions: gerrit.QueryOptions{
 			Query: []string{query},
 			Limit: 25,
 		},
 		ChangeOptions: gerrit.ChangeOptions{
@@ -59,75 +62,82 @@ func (s service) List(ctx context.Context, rs issues.RepoSpec, opt issues.IssueL
 		},
 	})
 	if err != nil {
 		return nil, err
 	}
-	var is []issues.Issue
-	for _, change := range *changes {
+	var is []changes.Change
+	for _, change := range *cs {
 		if change.Status == "DRAFT" {
 			continue
 		}
-		is = append(is, issues.Issue{
+		is = append(is, changes.Change{
 			ID:    uint64(change.Number),
 			State: state(change.Status),
 			Title: change.Subject,
 			//Labels: labels, // TODO.
-			Comment: issues.Comment{
-				User:      s.gerritUser(change.Owner),
-				CreatedAt: time.Time(change.Created),
-			},
-			Replies: len(change.Messages),
+			Author:    s.gerritUser(change.Owner),
+			CreatedAt: time.Time(change.Created),
+			Replies:   len(change.Messages),
 		})
 	}
 	//sort.Sort(sort.Reverse(byID(is))) // For some reason, IDs don't completely line up with created times.
 	sort.Slice(is, func(i, j int) bool {
 		return is[i].CreatedAt.After(is[j].CreatedAt)
 	})
 	return is, nil
 }
 
-func (s service) Count(_ context.Context, rs issues.RepoSpec, opt issues.IssueListOptions) (uint64, error) {
+func (s service) Count(_ context.Context, repo string, opt changes.ListOptions) (uint64, error) {
 	// TODO.
 	return 0, nil
 }
 
-func (s service) Get(ctx context.Context, _ issues.RepoSpec, id uint64) (issues.Issue, error) {
+func (s service) Get(ctx context.Context, _ string, id uint64) (changes.Change, error) {
 	change, _, err := s.cl.Changes.GetChange(fmt.Sprint(id), &gerrit.ChangeOptions{
 		AdditionalFields: []string{"DETAILED_ACCOUNTS"},
 	})
 	if err != nil {
-		return issues.Issue{}, err
+		return changes.Change{}, err
 	}
 	if change.Status == "DRAFT" {
-		return issues.Issue{}, os.ErrNotExist
+		return changes.Change{}, os.ErrNotExist
 	}
-	return issues.Issue{
-		ID:    id,
-		State: state(change.Status),
-		Title: change.Subject,
-		Comment: issues.Comment{
-			User:      s.gerritUser(change.Owner),
-			CreatedAt: time.Time(change.Created),
-			Editable:  false,
-		},
+	return changes.Change{
+		ID:        id,
+		State:     state(change.Status),
+		Title:     change.Subject,
+		Author:    s.gerritUser(change.Owner),
+		CreatedAt: time.Time(change.Created),
 	}, nil
 }
 
-func state(status string) issues.State {
+func state(status string) changes.State {
 	switch status {
 	case "NEW":
-		return issues.OpenState
-	case "ABANDONED", "MERGED":
-		return issues.ClosedState
+		return changes.OpenState
+	case "ABANDONED":
+		return changes.ClosedState
+	case "MERGED":
+		return changes.MergedState
 	case "DRAFT":
 		panic("not sure how to deal with DRAFT status")
 	default:
 		panic("unreachable")
 	}
 }
 
-func (s service) ListComments(ctx context.Context, _ issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Comment, error) {
+func (s service) GetDiff(ctx context.Context, _ string, id uint64) ([]byte, error) {
+	diff, _, err := s.cl.Changes.GetPatch(fmt.Sprint(id), "current", &gerrit.PatchOptions{
+		Path: "src", // TODO.
+	})
+	if err != nil {
+		return nil, err
+	}
+	return []byte(*diff), nil
+}
+
+func (s service) ListComments(ctx context.Context, _ string, id uint64, opt *changes.ListCommentsOptions) ([]issues.Comment, error) {
 	// TODO: Pagination. Respect opt.Start and opt.Length, if given.
 
 	change, _, err := s.cl.Changes.GetChangeDetail(fmt.Sprint(id), nil)
 	if err != nil {
 		return nil, err
@@ -143,35 +153,15 @@ func (s service) ListComments(ctx context.Context, _ issues.RepoSpec, id uint64,
 		})
 	}
 	return comments, nil
 }
 
-func (s service) ListEvents(ctx context.Context, _ issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Event, error) {
+func (s service) ListEvents(ctx context.Context, _ string, id uint64, opt *changes.ListCommentsOptions) ([]issues.Event, error) {
 	// TODO.
 	return nil, nil
 }
 
-func (s service) CreateComment(_ context.Context, rs issues.RepoSpec, id uint64, c issues.Comment) (issues.Comment, error) {
-	// TODO.
-	return issues.Comment{}, fmt.Errorf("CreateComment: not implemented")
-}
-
-func (s service) Create(_ context.Context, rs issues.RepoSpec, i issues.Issue) (issues.Issue, error) {
-	// TODO.
-	return issues.Issue{}, fmt.Errorf("Create: not implemented")
-}
-
-func (s service) Edit(_ context.Context, rs issues.RepoSpec, id uint64, ir issues.IssueRequest) (issues.Issue, []issues.Event, error) {
-	// TODO.
-	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) {
-	// TODO.
-	return issues.Comment{}, fmt.Errorf("EditComment: not implemented")
-}
-
 func (s service) gerritUser(user gerrit.AccountInfo) users.User {
 	return users.User{
 		UserSpec: users.UserSpec{
 			ID:     uint64(user.AccountID),
 			Domain: s.domain,
@@ -181,18 +171,11 @@ func (s service) gerritUser(user gerrit.AccountInfo) users.User {
 		//Email:     user.Email,
 		AvatarURL: fmt.Sprintf("https://%s/accounts/%d/avatar?s=96", s.domain, user.AccountID),
 	}
 }
 
-func project(rs issues.RepoSpec) string {
-	if i := strings.IndexByte(rs.URI, '/'); i != -1 {
-		return rs.URI[i+1:]
+func project(repo string) string {
+	if i := strings.IndexByte(repo, '/'); i != -1 {
+		return repo[i+1:]
 	}
 	return ""
 }
-
-// byID implements sort.Interface.
-type byID []issues.Issue
-
-func (s byID) Len() int           { return len(s) }
-func (s byID) Less(i, j int) bool { return s[i].ID < s[j].ID }
-func (s byID) Swap(i, j int)      { s[i], s[j] = s[j], s[i] }
githubapi/githubapi.go
@@ -1,6 +1,6 @@
-// Package githubapi implements a read-only issues.Service using
+// Package githubapi implements a read-only changes.Service using
 // using GitHub GraphQL API v4 clients that serves PRs.
 package githubapi
 
 import (
 	"context"
@@ -15,14 +15,14 @@ import (
 	"github.com/shurcooL/notifications"
 	"github.com/shurcooL/reactions"
 	"github.com/shurcooL/users"
 )
 
-// NewService creates a GitHub-backed issues.Service using given GitHub clients.
+// NewService creates a GitHub-backed changes.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 {
+func NewService(clientV3 *github.Client, clientV4 *githubql.Client, notifications notifications.ExternalService, users users.Service) changes.Service {
 	s := service{
 		clV3:          clientV3,
 		clV4:          clientV4,
 		notifications: notifications,
 		users:         users,
@@ -47,25 +47,25 @@ type service struct {
 }
 
 // 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) {
+func (s service) List(ctx context.Context, rs string, opt changes.ListOptions) ([]changes.Change, 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):
+	case changes.StateFilter(changes.OpenState):
 		states = []githubql.PullRequestState{githubql.PullRequestStateOpen}
-	case issues.StateFilter(changes.ClosedState):
+	case changes.StateFilter(changes.ClosedState):
 		states = []githubql.PullRequestState{githubql.PullRequestStateClosed}
-	case issues.StateFilter(changes.MergedState):
+	case changes.StateFilter(changes.MergedState):
 		states = []githubql.PullRequestState{githubql.PullRequestStateMerged}
-	case issues.AllStates:
+	case changes.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)
 	}
@@ -98,49 +98,47 @@ func (s service) List(ctx context.Context, rs issues.RepoSpec, opt issues.IssueL
 	}
 	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 is []changes.Change
+	for _, pr := range q.Repository.PullRequests.Nodes {
 		var labels []issues.Label
-		for _, l := range issue.Labels.Nodes {
+		for _, l := range pr.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,
+		is = append(is, changes.Change{
+			ID:        pr.Number,
+			State:     ghPRState(pr.State),
+			Title:     pr.Title,
+			Labels:    labels,
+			Author:    ghActor(pr.Author),
+			CreatedAt: pr.CreatedAt.Time,
+			Replies:   pr.Comments.TotalCount,
 		})
 	}
 	return is, nil
 }
 
-func (s service) Count(ctx context.Context, rs issues.RepoSpec, opt issues.IssueListOptions) (uint64, error) {
+func (s service) Count(ctx context.Context, rs string, opt changes.ListOptions) (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):
+	case changes.StateFilter(changes.OpenState):
 		states = []githubql.PullRequestState{githubql.PullRequestStateOpen}
-	case issues.StateFilter(changes.ClosedState):
+	case changes.StateFilter(changes.ClosedState):
 		states = []githubql.PullRequestState{githubql.PullRequestStateClosed}
-	case issues.StateFilter(changes.MergedState):
+	case changes.StateFilter(changes.MergedState):
 		states = []githubql.PullRequestState{githubql.PullRequestStateMerged}
-	case issues.AllStates:
+	case changes.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)
 	}
@@ -158,15 +156,15 @@ func (s service) Count(ctx context.Context, rs issues.RepoSpec, opt issues.Issue
 	}
 	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) {
+func (s service) Get(ctx context.Context, rs string, id uint64) (changes.Change, error) {
 	repo, err := ghRepoSpec(rs)
 	if err != nil {
 		// TODO: Map to 400 Bad Request HTTP error.
-		return issues.Issue{}, err
+		return changes.Change{}, err
 	}
 	var q struct {
 		Repository struct {
 			PullRequest struct {
 				Number          uint64
@@ -183,11 +181,11 @@ func (s service) Get(ctx context.Context, rs issues.RepoSpec, id uint64) (issues
 		"repositoryName":  githubql.String(repo.Repo),
 		"prNumber":        githubql.Int(id),
 	}
 	err = s.clV4.Query(ctx, &q, variables)
 	if err != nil {
-		return issues.Issue{}, err
+		return changes.Change{}, err
 	}
 
 	if s.currentUser.ID != 0 {
 		// Mark as read.
 		err = s.markRead(ctx, rs, id)
@@ -196,23 +194,33 @@ func (s service) Get(ctx context.Context, rs issues.RepoSpec, id uint64) (issues
 		}
 	}
 
 	// 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),
-		},
+	return changes.Change{
+		ID:        pr.Number,
+		State:     ghPRState(pr.State),
+		Title:     pr.Title,
+		Author:    ghActor(pr.Author),
+		CreatedAt: pr.CreatedAt.Time,
 	}, nil
 }
 
-func (s service) ListComments(ctx context.Context, rs issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Comment, error) {
+func (s service) GetDiff(ctx context.Context, rs string, id uint64) ([]byte, error) {
+	repo, err := ghRepoSpec(rs)
+	if err != nil {
+		// TODO: Map to 400 Bad Request HTTP error.
+		return nil, err
+	}
+	diff, _, err := s.clV3.PullRequests.GetRaw(ctx, repo.Owner, repo.Repo, int(id), github.RawOptions{Type: github.Diff})
+	if err != nil {
+		return nil, err
+	}
+	return []byte(diff), nil
+}
+
+func (s service) ListComments(ctx context.Context, rs string, id uint64, opt *changes.ListCommentsOptions) ([]issues.Comment, error) {
 	// TODO: Respect opt.Start and opt.Length, if given.
 
 	repo, err := ghRepoSpec(rs)
 	if err != nil {
 		return nil, err
@@ -329,11 +337,11 @@ func (s service) ListComments(ctx context.Context, rs issues.RepoSpec, id uint64
 	}
 
 	return comments, nil
 }
 
-func (s service) ListEvents(ctx context.Context, rs issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Event, error) {
+func (s service) ListEvents(ctx context.Context, rs string, id uint64, opt *changes.ListCommentsOptions) ([]issues.Event, error) {
 	repo, err := ghRepoSpec(rs)
 	if err != nil {
 		// TODO: Map to 400 Bad Request HTTP error.
 		return nil, err
 	}
@@ -443,39 +451,20 @@ func (s service) ListEvents(ctx context.Context, rs issues.RepoSpec, id uint64,
 		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, "/")
+func ghRepoSpec(rs string) (repoSpec, error) {
+	// The "github.com/" prefix is expected to be included.
+	ghOwnerRepo := strings.Split(rs, "/")
 	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{}, fmt.Errorf(`RepoSpec is not of form "github.com/owner/repo": %q`, rs)
 	}
 	return repoSpec{
 		Owner: ghOwnerRepo[1],
 		Repo:  ghOwnerRepo[2],
 	}, nil
@@ -645,12 +634,12 @@ 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 {
+func (s service) markRead(ctx context.Context, repo string, id uint64) error {
 	if s.notifications == nil {
 		return nil
 	}
 
-	return s.notifications.MarkRead(ctx, notifications.RepoSpec(repo), threadType, id)
+	return s.notifications.MarkRead(ctx, notifications.RepoSpec{URI: repo}, threadType, id)
 }
maintner/maintner.go
@@ -1,61 +1,62 @@
-// Package maintner implements a read-only issues.Service using
+// Package maintner implements a read-only changes.Service using
 // a x/build/maintner corpus that serves Gerrit changes.
 package maintner
 
 import (
 	"context"
 	"fmt"
 	"log"
 	"sort"
 	"strings"
 
+	"dmitri.shuralyov.com/changes"
 	"github.com/shurcooL/issues"
 	"github.com/shurcooL/users"
 	"golang.org/x/build/maintner"
 )
 
-// NewService creates an issues.Service backed with the given corpus.
+// NewService creates an changes.Service backed with the given corpus.
 // However, it serves Gerrit changes, not GitHub issues.
-func NewService(corpus *maintner.Corpus) issues.Service {
+func NewService(corpus *maintner.Corpus) changes.Service {
 	return service{
 		c: corpus,
 	}
 }
 
 type service struct {
 	c *maintner.Corpus
 }
 
-func (s service) List(ctx context.Context, rs issues.RepoSpec, opt issues.IssueListOptions) ([]issues.Issue, error) {
+func (s service) List(ctx context.Context, repo string, opt changes.ListOptions) ([]changes.Change, error) {
 	// TODO: Pagination. Respect opt.Start and opt.Length, if given.
 
-	var is []issues.Issue
+	var is []changes.Change
 
-	project := s.c.Gerrit().Project(serverProject(rs))
+	project := s.c.Gerrit().Project(serverProject(repo))
 	err := project.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
 		if cl.Status == "" {
 			log.Printf("empty status for CL %d\n", cl.Number)
 			return nil
 		}
 		state := state(cl.Status)
 		switch {
-		case opt.State == issues.StateFilter(issues.OpenState) && state != issues.OpenState:
+		case opt.State == changes.StateFilter(changes.OpenState) && state != changes.OpenState:
 			return nil
-		case opt.State == issues.StateFilter(issues.ClosedState) && state != issues.ClosedState:
+		case opt.State == changes.StateFilter(changes.ClosedState) && state != changes.ClosedState:
+			return nil
+		case opt.State == changes.StateFilter(changes.MergedState) && state != changes.MergedState:
 			return nil
 		}
 
-		is = append(is, issues.Issue{
+		is = append(is, changes.Change{
 			ID:    uint64(cl.Number),
 			State: state,
 			Title: firstParagraph(cl.Commit.Msg),
 			//Labels: labels, // TODO.
-			Comment: issues.Comment{
-				User:      gerritUser(cl.Commit.Author),
-				CreatedAt: cl.Created,
-			},
+			Author:    gerritUser(cl.Commit.Author),
+			CreatedAt: cl.Created,
 			//Replies: len(cl.Messages),
 		})
 
 		return nil
 	})
@@ -69,23 +70,25 @@ func (s service) List(ctx context.Context, rs issues.RepoSpec, opt issues.IssueL
 	})
 
 	return is, nil
 }
 
-func (s service) Count(_ context.Context, rs issues.RepoSpec, opt issues.IssueListOptions) (uint64, error) {
+func (s service) Count(_ context.Context, repo string, opt changes.ListOptions) (uint64, error) {
 	var count uint64
 
-	project := s.c.Gerrit().Project(serverProject(rs))
+	project := s.c.Gerrit().Project(serverProject(repo))
 	err := project.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
 		if cl.Status == "" {
 			return nil
 		}
 		state := state(cl.Status)
 		switch {
-		case opt.State == issues.StateFilter(issues.OpenState) && state != issues.OpenState:
+		case opt.State == changes.StateFilter(changes.OpenState) && state != changes.OpenState:
+			return nil
+		case opt.State == changes.StateFilter(changes.ClosedState) && state != changes.ClosedState:
 			return nil
-		case opt.State == issues.StateFilter(issues.ClosedState) && state != issues.ClosedState:
+		case opt.State == changes.StateFilter(changes.MergedState) && state != changes.MergedState:
 			return nil
 		}
 
 		count++
 
@@ -96,58 +99,45 @@ func (s service) Count(_ context.Context, rs issues.RepoSpec, opt issues.IssueLi
 	}
 
 	return count, nil
 }
 
-func (s service) Get(ctx context.Context, _ issues.RepoSpec, id uint64) (issues.Issue, error) {
+func (s service) Get(ctx context.Context, _ string, id uint64) (changes.Change, error) {
+	// TODO.
+	return changes.Change{}, fmt.Errorf("Get: not implemented")
+}
+
+func (s service) GetDiff(ctx context.Context, _ string, id uint64) ([]byte, error) {
 	// TODO.
-	return issues.Issue{}, fmt.Errorf("Get: not implemented")
+	return nil, fmt.Errorf("GetDiff: not implemented")
 }
 
-func state(status string) issues.State {
+func state(status string) changes.State {
 	switch status {
 	case "new":
-		return issues.OpenState
-	case "abandoned", "merged":
-		return issues.ClosedState
+		return changes.OpenState
+	case "abandoned":
+		return changes.ClosedState
+	case "merged":
+		return changes.MergedState
 	case "draft":
 		panic("not sure how to deal with draft status")
 	default:
 		panic(fmt.Errorf("unrecognized status %q", status))
 	}
 }
 
-func (s service) ListComments(ctx context.Context, _ issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Comment, error) {
+func (s service) ListComments(ctx context.Context, _ string, id uint64, opt *changes.ListCommentsOptions) ([]issues.Comment, error) {
 	// TODO.
 	return nil, fmt.Errorf("ListComments: not implemented")
 }
 
-func (s service) ListEvents(ctx context.Context, _ issues.RepoSpec, id uint64, opt *issues.ListOptions) ([]issues.Event, error) {
+func (s service) ListEvents(ctx context.Context, _ string, id uint64, opt *changes.ListCommentsOptions) ([]issues.Event, error) {
 	// TODO.
 	return nil, fmt.Errorf("ListEvents: not implemented")
 }
 
-func (s service) CreateComment(_ context.Context, rs issues.RepoSpec, id uint64, c issues.Comment) (issues.Comment, error) {
-	// TODO.
-	return issues.Comment{}, fmt.Errorf("CreateComment: not implemented")
-}
-
-func (s service) Create(_ context.Context, rs issues.RepoSpec, i issues.Issue) (issues.Issue, error) {
-	// TODO.
-	return issues.Issue{}, fmt.Errorf("Create: not implemented")
-}
-
-func (s service) Edit(_ context.Context, rs issues.RepoSpec, id uint64, ir issues.IssueRequest) (issues.Issue, []issues.Event, error) {
-	// TODO.
-	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) {
-	// TODO.
-	return issues.Comment{}, fmt.Errorf("EditComment: not implemented")
-}
-
 func gerritUser(user *maintner.GitPerson) users.User {
 	return users.User{
 		UserSpec: users.UserSpec{
 			ID:     0,  // TODO.
 			Domain: "", // TODO.
@@ -157,32 +147,32 @@ func gerritUser(user *maintner.GitPerson) users.User {
 		Email: user.Email(),
 		//AvatarURL: fmt.Sprintf("https://%s/accounts/%d/avatar?s=96", s.domain, user.AccountID),
 	}
 }
 
-func serverProject(rs issues.RepoSpec) (server, project string) {
-	i := strings.IndexByte(rs.URI, '/')
+func serverProject(repo string) (server, project string) {
+	i := strings.IndexByte(repo, '/')
 	if i == -1 {
 		return "", ""
 	}
-	return rs.URI[:i], rs.URI[i+1:]
+	return repo[:i], repo[i+1:]
 }
 
-func server(rs issues.RepoSpec) string {
-	i := strings.IndexByte(rs.URI, '/')
+func server(repo string) string {
+	i := strings.IndexByte(repo, '/')
 	if i == -1 {
 		return ""
 	}
-	return rs.URI[:i]
+	return repo[:i]
 }
 
-func project(rs issues.RepoSpec) string {
-	i := strings.IndexByte(rs.URI, '/')
+func project(repo string) string {
+	i := strings.IndexByte(repo, '/')
 	if i == -1 {
 		return ""
 	}
-	return rs.URI[i+1:]
+	return repo[i+1:]
 }
 
 // firstParagraph returns the first paragraph of text s.
 func firstParagraph(s string) string {
 	i := strings.Index(s, "\n\n")