Files
diple/github_people.go
2026-07-29 13:12:39 +02:00

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")
}
}