package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "slices" "strconv" "strings" "time" ) const repositoryReviewersQuery = ` query RepositoryReviewers($owner: String!, $name: String!, $after: String) { repository(owner: $owner, name: $name) { collaborators(first: 100, after: $after, affiliation: ALL) { pageInfo { hasNextPage endCursor } nodes { id login name } } } }` const repositoryAssigneesQuery = ` query RepositoryAssignees($owner: String!, $name: String!, $after: String) { repository(owner: $owner, name: $name) { assignableUsers(first: 100, after: $after) { pageInfo { hasNextPage endCursor } nodes { id login name } } } }` const repositoryContributionsQuery = ` query RepositoryContributions($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { defaultBranchRef { target { ... on Commit { history(first: 100) { nodes { committedDate additions author { user { login } } } } } } } } }` type repositoryUserNode struct { ID, Login, Name string } type repositoryUserConnection struct { PageInfo githubPageInfo Nodes []repositoryUserNode } type repositoryContribution struct { CommittedDate time.Time Additions int Author struct { User *githubActor } } func (c *GitHubClient) ListRepositoryUsers( ctx context.Context, owner, repo string, ) ([]RepositoryUser, error) { reviewers, err := c.listRepositoryUserConnection( ctx, repositoryReviewersQuery, "collaborators", owner, repo, ) if err != nil { return nil, fmt.Errorf("list eligible reviewers: %w", err) } assignees, err := c.listRepositoryUserConnection( ctx, repositoryAssigneesQuery, "assignableUsers", owner, repo, ) if err != nil { return nil, fmt.Errorf("list eligible assignees: %w", err) } users := make(map[string]RepositoryUser, len(reviewers)+len(assignees)) for _, reviewer := range reviewers { key := strings.ToLower(reviewer.Login) users[key] = RepositoryUser{ ID: reviewer.ID, Login: reviewer.Login, Name: reviewer.Name, CanReview: true, } } for _, assignee := range assignees { key := strings.ToLower(assignee.Login) user := users[key] if user.ID == "" { user.ID, user.Login, user.Name = assignee.ID, assignee.Login, assignee.Name } user.CanAssign = true users[key] = user } if contributions, contributionErr := c.listRecentContributions(ctx, owner, repo); contributionErr == nil { for _, contribution := range contributions { if contribution.Author.User == nil || contribution.Author.User.Login == "" { continue } key := strings.ToLower(contribution.Author.User.Login) user, exists := users[key] if !exists { continue } user.RecentCommits++ user.RecentAdditions += max(0, contribution.Additions) if contribution.CommittedDate.After(user.LastContributionAt) { user.LastContributionAt = contribution.CommittedDate } users[key] = user } } result := make([]RepositoryUser, 0, len(users)) for _, user := range users { result = append(result, user) } sortRepositoryUsers(result) return result, nil } func (c *GitHubClient) listRecentContributions( ctx context.Context, owner, repo string, ) ([]repositoryContribution, error) { var data struct { Repository *struct { DefaultBranchRef *struct { Target *struct { History struct { Nodes []repositoryContribution } } } } } if err := c.query(ctx, repositoryContributionsQuery, map[string]any{ "owner": owner, "name": repo, }, &data); err != nil { return nil, err } if data.Repository == nil { return nil, errors.New("repository was not found") } if data.Repository.DefaultBranchRef == nil || data.Repository.DefaultBranchRef.Target == nil { return nil, nil } return data.Repository.DefaultBranchRef.Target.History.Nodes, nil } func (c *GitHubClient) listRepositoryUserConnection( ctx context.Context, query, field, owner, repo string, ) ([]repositoryUserNode, error) { var result []repositoryUserNode cursor := "" for { var data struct { Repository *struct { Collaborators repositoryUserConnection AssignableUsers repositoryUserConnection `json:"assignableUsers"` } } if err := c.query(ctx, query, map[string]any{ "owner": owner, "name": repo, "after": nullableCursor(cursor), }, &data); err != nil { return nil, err } if data.Repository == nil { return nil, errors.New("repository was not found") } connection := data.Repository.Collaborators if field == "assignableUsers" { connection = data.Repository.AssignableUsers } result = append(result, connection.Nodes...) if !connection.PageInfo.HasNextPage { return result, nil } if connection.PageInfo.EndCursor == "" || connection.PageInfo.EndCursor == cursor { return nil, errors.New("GitHub returned an empty user pagination cursor") } cursor = connection.PageInfo.EndCursor } } func sortRepositoryUsers(users []RepositoryUser) { slices.SortStableFunc(users, func(left, right RepositoryUser) int { return strings.Compare(strings.ToLower(left.Login), strings.ToLower(right.Login)) }) } func (c *GitHubClient) UpdatePullRequestPeople( ctx context.Context, owner, repo string, number int, update PullRequestPeopleUpdate, ) (PullRequestPeople, error) { added, removed := loginDifference(update.Reviewers, update.CurrentReviewers), loginDifference(update.CurrentReviewers, update.Reviewers) result := PullRequestPeople{ Reviewers: append([]string(nil), update.CurrentReviewers...), Assignees: append([]string(nil), update.CurrentAssignees...), } if len(added) > 0 { if err := c.updateReviewRequests(ctx, http.MethodPost, owner, repo, number, added); err != nil { return result, fmt.Errorf("add reviewers: %w", err) } result.Reviewers = normalizedLogins(append(result.Reviewers, added...)) } if len(removed) > 0 { if err := c.updateReviewRequests(ctx, http.MethodDelete, owner, repo, number, removed); err != nil { return result, fmt.Errorf("remove reviewers: %w", err) } result.Reviewers = append([]string(nil), update.Reviewers...) } if !equalLoginSets(update.Assignees, update.CurrentAssignees) { if err := c.replaceAssignees(ctx, owner, repo, number, update.Assignees); err != nil { return result, fmt.Errorf("update assignees: %w", err) } result.Assignees = append([]string(nil), update.Assignees...) } result.Reviewers = append([]string(nil), update.Reviewers...) return result, nil } func equalLoginSets(left, right []string) bool { return len(loginDifference(left, right)) == 0 && len(loginDifference(right, left)) == 0 } func loginDifference(left, right []string) []string { existing := make(map[string]bool, len(right)) for _, login := range right { existing[strings.ToLower(login)] = true } var result []string for _, login := range left { if !existing[strings.ToLower(login)] { result = append(result, login) } } return result } func (c *GitHubClient) updateReviewRequests( ctx context.Context, method, owner, repo string, number int, reviewers []string, ) error { return c.restJSON( ctx, method, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+ "/pulls/"+strconv.Itoa(number)+"/requested_reviewers", map[string]any{"reviewers": reviewers}, nil, ) } func (c *GitHubClient) replaceAssignees( ctx context.Context, owner, repo string, number int, assignees []string, ) error { return c.restJSON( ctx, http.MethodPatch, "/repos/"+url.PathEscape(owner)+"/"+url.PathEscape(repo)+ "/issues/"+strconv.Itoa(number), map[string]any{"assignees": assignees}, nil, ) } func (c *GitHubClient) restJSON( ctx context.Context, method, requestPath string, input, output any, ) error { var body io.Reader if input != nil { encoded, err := json.Marshal(input) if err != nil { return err } body = bytes.NewReader(encoded) } request, err := http.NewRequestWithContext( ctx, method, c.restBaseURL()+requestPath, body, ) if err != nil { return err } request.Header.Set("Authorization", "Bearer "+c.token) request.Header.Set("Accept", "application/vnd.github+json") request.Header.Set("Content-Type", "application/json") request.Header.Set("User-Agent", "diple") response, err := c.http.Do(request) if err != nil { return err } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { data, _ := io.ReadAll(io.LimitReader(response.Body, 4096)) return fmt.Errorf( "GitHub returned %s: %s", response.Status, strings.TrimSpace(string(data)), ) } if output == nil || response.StatusCode == http.StatusNoContent { return nil } if err := json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(output); err != nil { return fmt.Errorf("decode GitHub response: %w", err) } return nil } func (c *GitHubClient) restBaseURL() string { base := strings.TrimSuffix(c.endpoint, "/") switch { case base == "https://api.github.com/graphql": return "https://api.github.com" case strings.HasSuffix(base, "/api/graphql"): return strings.TrimSuffix(base, "/api/graphql") + "/api/v3" default: return strings.TrimSuffix(base, "/graphql") } }