329 lines
9.1 KiB
Go
329 lines
9.1 KiB
Go
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")
|
|
}
|
|
}
|