add reviewer assigning
This commit is contained in:
328
github_people.go
Normal file
328
github_people.go
Normal file
@@ -0,0 +1,328 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user