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

Add changes, githubapi.
dmitshur committed 2 years ago commit 391e3d90eb222279a56521627867335e3c409cf2
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)
+}