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

Implement and use changes.Service interface.

Add GetDiff method to it.
dmitshur committed 6 years ago commit 1725c4c4235d315deaa2317977b3a3906328f339
Collapse all
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")