Replace the top-level organization(login: $owner) query with
repository.owner { ... on Organization { teams } }. This uses
GraphQL inline fragments to conditionally fetch team data only
when the repo owner is an Organization, eliminating the need to
handle 'Could not resolve to an Organization' errors for
personal repos.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
709 lines
21 KiB
Go
709 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
"github.com/shurcooL/githubv4"
|
|
)
|
|
|
|
type PullRequestReviewState int
|
|
|
|
const (
|
|
ReviewApprove PullRequestReviewState = iota
|
|
ReviewRequestChanges
|
|
ReviewComment
|
|
)
|
|
|
|
type PullRequestReviewInput struct {
|
|
Body string
|
|
State PullRequestReviewState
|
|
}
|
|
|
|
type PullRequestReviews struct {
|
|
Nodes []PullRequestReview
|
|
PageInfo struct {
|
|
HasNextPage bool
|
|
EndCursor string
|
|
}
|
|
TotalCount int
|
|
}
|
|
|
|
type PullRequestReview struct {
|
|
ID string `json:"id"`
|
|
Author CommentAuthor `json:"author"`
|
|
AuthorAssociation string `json:"authorAssociation"`
|
|
Body string `json:"body"`
|
|
SubmittedAt *time.Time `json:"submittedAt"`
|
|
IncludesCreatedEdit bool `json:"includesCreatedEdit"`
|
|
ReactionGroups ReactionGroups `json:"reactionGroups"`
|
|
State string `json:"state"`
|
|
URL string `json:"url,omitempty"`
|
|
Commit Commit `json:"commit"`
|
|
}
|
|
|
|
func (prr PullRequestReview) Identifier() string {
|
|
return prr.ID
|
|
}
|
|
|
|
func (prr PullRequestReview) AuthorLogin() string {
|
|
return prr.Author.Login
|
|
}
|
|
|
|
func (prr PullRequestReview) Association() string {
|
|
return prr.AuthorAssociation
|
|
}
|
|
|
|
func (prr PullRequestReview) Content() string {
|
|
return prr.Body
|
|
}
|
|
|
|
func (prr PullRequestReview) Created() time.Time {
|
|
if prr.SubmittedAt == nil {
|
|
return time.Time{}
|
|
}
|
|
return *prr.SubmittedAt
|
|
}
|
|
|
|
func (prr PullRequestReview) HiddenReason() string {
|
|
return ""
|
|
}
|
|
|
|
func (prr PullRequestReview) IsEdited() bool {
|
|
return prr.IncludesCreatedEdit
|
|
}
|
|
|
|
func (prr PullRequestReview) IsHidden() bool {
|
|
return false
|
|
}
|
|
|
|
func (prr PullRequestReview) Link() string {
|
|
return prr.URL
|
|
}
|
|
|
|
func (prr PullRequestReview) Reactions() ReactionGroups {
|
|
return prr.ReactionGroups
|
|
}
|
|
|
|
func (prr PullRequestReview) Status() string {
|
|
return prr.State
|
|
}
|
|
|
|
type PullRequestReviewStatus struct {
|
|
ChangesRequested bool
|
|
Approved bool
|
|
ReviewRequired bool
|
|
}
|
|
|
|
func (pr *PullRequest) ReviewStatus() PullRequestReviewStatus {
|
|
var status PullRequestReviewStatus
|
|
switch pr.ReviewDecision {
|
|
case "CHANGES_REQUESTED":
|
|
status.ChangesRequested = true
|
|
case "APPROVED":
|
|
status.Approved = true
|
|
case "REVIEW_REQUIRED":
|
|
status.ReviewRequired = true
|
|
}
|
|
return status
|
|
}
|
|
|
|
func (pr *PullRequest) DisplayableReviews() PullRequestReviews {
|
|
published := []PullRequestReview{}
|
|
for _, prr := range pr.Reviews.Nodes {
|
|
//Dont display pending reviews
|
|
//Dont display commenting reviews without top level comment body
|
|
if prr.State != "PENDING" && !(prr.State == "COMMENTED" && prr.Body == "") {
|
|
published = append(published, prr)
|
|
}
|
|
}
|
|
return PullRequestReviews{Nodes: published, TotalCount: len(published)}
|
|
}
|
|
|
|
type ReviewRequests struct {
|
|
Nodes []struct {
|
|
RequestedReviewer RequestedReviewer
|
|
}
|
|
}
|
|
|
|
type RequestedReviewer struct {
|
|
TypeName string `json:"__typename"`
|
|
Login string `json:"login"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Organization struct {
|
|
Login string `json:"login"`
|
|
} `json:"organization"`
|
|
}
|
|
|
|
const teamTypeName = "Team"
|
|
const botTypeName = "Bot"
|
|
|
|
func (r RequestedReviewer) LoginOrSlug() string {
|
|
if r.TypeName == teamTypeName {
|
|
return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug)
|
|
}
|
|
return r.Login
|
|
}
|
|
|
|
// DisplayName returns a user-friendly name for the reviewer.
|
|
// For Copilot bot, returns "Copilot (AI)". For teams, returns "org/slug".
|
|
// For users, returns "login (Name)" if name is available, otherwise just login.
|
|
func (r RequestedReviewer) DisplayName() string {
|
|
if r.TypeName == teamTypeName {
|
|
return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug)
|
|
}
|
|
if r.TypeName == botTypeName && r.Login == CopilotReviewerLogin {
|
|
return "Copilot (AI)"
|
|
}
|
|
if r.Name != "" {
|
|
return fmt.Sprintf("%s (%s)", r.Login, r.Name)
|
|
}
|
|
return r.Login
|
|
}
|
|
|
|
func (r ReviewRequests) Logins() []string {
|
|
logins := make([]string, len(r.Nodes))
|
|
for i, r := range r.Nodes {
|
|
logins[i] = r.RequestedReviewer.LoginOrSlug()
|
|
}
|
|
return logins
|
|
}
|
|
|
|
// DisplayNames returns user-friendly display names for all requested reviewers.
|
|
func (r ReviewRequests) DisplayNames() []string {
|
|
names := make([]string, len(r.Nodes))
|
|
for i, r := range r.Nodes {
|
|
names[i] = r.RequestedReviewer.DisplayName()
|
|
}
|
|
return names
|
|
}
|
|
|
|
// ReviewerCandidate represents a potential reviewer for a pull request.
|
|
// This can be a User, Bot, or Team. It mirrors AssignableActor but adds
|
|
// team support (teams can review but not be assigned) and drops the ID method.
|
|
// ReviewerUser and ReviewerBot are thin wrappers around AssignableUser and
|
|
// AssignableBot that satisfy this interface.
|
|
type ReviewerCandidate interface {
|
|
DisplayName() string
|
|
Login() string
|
|
|
|
sealedReviewerCandidate()
|
|
}
|
|
|
|
// ReviewerUser is a user who can review a pull request.
|
|
type ReviewerUser struct {
|
|
AssignableUser
|
|
}
|
|
|
|
func NewReviewerUser(login, name string) ReviewerUser {
|
|
return ReviewerUser{
|
|
AssignableUser: NewAssignableUser("", login, name),
|
|
}
|
|
}
|
|
|
|
func (r ReviewerUser) sealedReviewerCandidate() {}
|
|
|
|
// ReviewerBot is a bot who can review a pull request.
|
|
type ReviewerBot struct {
|
|
AssignableBot
|
|
}
|
|
|
|
func NewReviewerBot(login string) ReviewerBot {
|
|
return ReviewerBot{
|
|
AssignableBot: NewAssignableBot("", login),
|
|
}
|
|
}
|
|
|
|
func (b ReviewerBot) DisplayName() string {
|
|
if b.login == CopilotReviewerLogin {
|
|
return fmt.Sprintf("%s (AI)", CopilotActorName)
|
|
}
|
|
return b.Login()
|
|
}
|
|
|
|
func (r ReviewerBot) sealedReviewerCandidate() {}
|
|
|
|
// ReviewerTeam is a team that can review a pull request.
|
|
type ReviewerTeam struct {
|
|
org string
|
|
teamSlug string
|
|
}
|
|
|
|
// NewReviewerTeam creates a new ReviewerTeam.
|
|
func NewReviewerTeam(orgName, teamSlug string) ReviewerTeam {
|
|
return ReviewerTeam{org: orgName, teamSlug: teamSlug}
|
|
}
|
|
|
|
func (r ReviewerTeam) DisplayName() string {
|
|
return fmt.Sprintf("%s/%s", r.org, r.teamSlug)
|
|
}
|
|
|
|
func (r ReviewerTeam) Login() string {
|
|
return fmt.Sprintf("%s/%s", r.org, r.teamSlug)
|
|
}
|
|
|
|
func (r ReviewerTeam) Slug() string {
|
|
return r.teamSlug
|
|
}
|
|
|
|
func (r ReviewerTeam) sealedReviewerCandidate() {}
|
|
|
|
func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error {
|
|
var mutation struct {
|
|
AddPullRequestReview struct {
|
|
ClientMutationID string
|
|
} `graphql:"addPullRequestReview(input:$input)"`
|
|
}
|
|
|
|
state := githubv4.PullRequestReviewEventComment
|
|
switch input.State {
|
|
case ReviewApprove:
|
|
state = githubv4.PullRequestReviewEventApprove
|
|
case ReviewRequestChanges:
|
|
state = githubv4.PullRequestReviewEventRequestChanges
|
|
}
|
|
|
|
body := githubv4.String(input.Body)
|
|
variables := map[string]interface{}{
|
|
"input": githubv4.AddPullRequestReviewInput{
|
|
PullRequestID: pr.ID,
|
|
Event: &state,
|
|
Body: &body,
|
|
},
|
|
}
|
|
|
|
return client.Mutate(repo.RepoHost(), "PullRequestReviewAdd", &mutation, variables)
|
|
}
|
|
|
|
// AddPullRequestReviews adds the given user and team reviewers to a pull request using the REST API.
|
|
// Team identifiers can be in "org/slug" format.
|
|
func AddPullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error {
|
|
if len(users) == 0 && len(teams) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// The API requires empty arrays instead of null values
|
|
if users == nil {
|
|
users = []string{}
|
|
}
|
|
|
|
path := fmt.Sprintf(
|
|
"repos/%s/%s/pulls/%d/requested_reviewers",
|
|
url.PathEscape(repo.RepoOwner()),
|
|
url.PathEscape(repo.RepoName()),
|
|
prNumber,
|
|
)
|
|
body := struct {
|
|
Reviewers []string `json:"reviewers"`
|
|
TeamReviewers []string `json:"team_reviewers"`
|
|
}{
|
|
Reviewers: users,
|
|
TeamReviewers: extractTeamSlugs(teams),
|
|
}
|
|
buf := &bytes.Buffer{}
|
|
if err := json.NewEncoder(buf).Encode(body); err != nil {
|
|
return err
|
|
}
|
|
// The endpoint responds with the updated pull request object; we don't need it here.
|
|
return client.REST(repo.RepoHost(), "POST", path, buf, nil)
|
|
}
|
|
|
|
// RemovePullRequestReviews removes requested reviewers from a pull request using the REST API.
|
|
// Team identifiers can be in "org/slug" format.
|
|
func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber int, users, teams []string) error {
|
|
if len(users) == 0 && len(teams) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// The API requires empty arrays instead of null values
|
|
if users == nil {
|
|
users = []string{}
|
|
}
|
|
|
|
path := fmt.Sprintf(
|
|
"repos/%s/%s/pulls/%d/requested_reviewers",
|
|
url.PathEscape(repo.RepoOwner()),
|
|
url.PathEscape(repo.RepoName()),
|
|
prNumber,
|
|
)
|
|
body := struct {
|
|
Reviewers []string `json:"reviewers"`
|
|
TeamReviewers []string `json:"team_reviewers"`
|
|
}{
|
|
Reviewers: users,
|
|
TeamReviewers: extractTeamSlugs(teams),
|
|
}
|
|
buf := &bytes.Buffer{}
|
|
if err := json.NewEncoder(buf).Encode(body); err != nil {
|
|
return err
|
|
}
|
|
// The endpoint responds with the updated pull request object; we don't need it here.
|
|
return client.REST(repo.RepoHost(), "DELETE", path, buf, nil)
|
|
}
|
|
|
|
// RequestReviewsByLogin sets requested reviewers on a pull request using the GraphQL mutation.
|
|
// This mutation replaces existing reviewers with the provided set unless union is true.
|
|
// Only available on github.com, not GHES.
|
|
// Bot logins should be passed without the [bot] suffix; it is appended automatically.
|
|
// Team slugs must be in the format "org/team-slug".
|
|
// When union is false (replace mode), passing empty slices will remove all reviewers.
|
|
func RequestReviewsByLogin(client *Client, repo ghrepo.Interface, prID string, userLogins, botLogins, teamSlugs []string, union bool) error {
|
|
// In union mode (additive), nothing to do if all lists are empty.
|
|
// In replace mode, we may still need to call the mutation to clear reviewers.
|
|
if union && len(userLogins) == 0 && len(botLogins) == 0 && len(teamSlugs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var mutation struct {
|
|
RequestReviewsByLogin struct {
|
|
ClientMutationId string `graphql:"clientMutationId"`
|
|
} `graphql:"requestReviewsByLogin(input: $input)"`
|
|
}
|
|
|
|
type RequestReviewsByLoginInput struct {
|
|
PullRequestID githubv4.ID `json:"pullRequestId"`
|
|
UserLogins *[]githubv4.String `json:"userLogins,omitempty"`
|
|
BotLogins *[]githubv4.String `json:"botLogins,omitempty"`
|
|
TeamSlugs *[]githubv4.String `json:"teamSlugs,omitempty"`
|
|
Union githubv4.Boolean `json:"union"`
|
|
}
|
|
|
|
input := RequestReviewsByLoginInput{
|
|
PullRequestID: githubv4.ID(prID),
|
|
Union: githubv4.Boolean(union),
|
|
}
|
|
|
|
userLoginValues := toGitHubV4Strings(userLogins, "")
|
|
input.UserLogins = &userLoginValues
|
|
|
|
// Bot logins require the [bot] suffix for the mutation
|
|
botLoginValues := toGitHubV4Strings(botLogins, "[bot]")
|
|
input.BotLogins = &botLoginValues
|
|
|
|
teamSlugValues := toGitHubV4Strings(teamSlugs, "")
|
|
input.TeamSlugs = &teamSlugValues
|
|
|
|
variables := map[string]interface{}{
|
|
"input": input,
|
|
}
|
|
|
|
return client.Mutate(repo.RepoHost(), "RequestReviewsByLogin", &mutation, variables)
|
|
}
|
|
|
|
// SuggestedReviewerActors fetches suggested reviewers for a pull request.
|
|
// It combines results from three sources using a cascading quota system:
|
|
// - suggestedReviewerActors - suggested based on PR activity (base quota: 5)
|
|
// - repository collaborators - all collaborators (base quota: 5 + unfilled from suggestions)
|
|
// - organization teams - all teams for org repos (base quota: 5 + unfilled from collaborators)
|
|
//
|
|
// This ensures we show up to 15 total candidates, with each source filling any
|
|
// unfilled quota from the previous source. Results are deduplicated.
|
|
// Returns the candidates, a MoreResults count, and an error.
|
|
func SuggestedReviewerActors(client *Client, repo ghrepo.Interface, prID string, query string) ([]ReviewerCandidate, int, error) {
|
|
// Fetch 10 from each source to allow cascading quota to fill from available results.
|
|
// Organization teams are fetched via repository.owner inline fragment, which
|
|
// gracefully returns empty data for personal (User-owned) repos.
|
|
// We also fetch unfiltered total counts via aliases for the "X more" display.
|
|
type responseData struct {
|
|
Node struct {
|
|
PullRequest struct {
|
|
Author struct {
|
|
Login string
|
|
}
|
|
SuggestedActors struct {
|
|
Nodes []struct {
|
|
IsAuthor bool
|
|
IsCommenter bool
|
|
Reviewer struct {
|
|
TypeName string `graphql:"__typename"`
|
|
User struct {
|
|
Login string
|
|
Name string
|
|
} `graphql:"... on User"`
|
|
Bot struct {
|
|
Login string
|
|
} `graphql:"... on Bot"`
|
|
}
|
|
}
|
|
} `graphql:"suggestedReviewerActors(first: 10, query: $query)"`
|
|
} `graphql:"... on PullRequest"`
|
|
} `graphql:"node(id: $id)"`
|
|
Repository struct {
|
|
Owner struct {
|
|
TypeName string `graphql:"__typename"`
|
|
Organization struct {
|
|
Teams struct {
|
|
Nodes []struct {
|
|
Slug string
|
|
}
|
|
} `graphql:"teams(first: 10, query: $query)"`
|
|
TeamsTotalCount struct {
|
|
TotalCount int
|
|
} `graphql:"teamsTotalCount: teams(first: 0)"`
|
|
} `graphql:"... on Organization"`
|
|
}
|
|
Collaborators struct {
|
|
Nodes []struct {
|
|
Login string
|
|
Name string
|
|
}
|
|
} `graphql:"collaborators(first: 10, query: $query)"`
|
|
CollaboratorsTotalCount struct {
|
|
TotalCount int
|
|
} `graphql:"collaboratorsTotalCount: collaborators(first: 0)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}
|
|
|
|
variables := map[string]interface{}{
|
|
"id": githubv4.ID(prID),
|
|
"query": githubv4.String(query),
|
|
"owner": githubv4.String(repo.RepoOwner()),
|
|
"name": githubv4.String(repo.RepoName()),
|
|
}
|
|
|
|
var result responseData
|
|
err := client.Query(repo.RepoHost(), "SuggestedReviewerActors", &result, variables)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Build candidates using cascading quota logic:
|
|
// Each source has a base quota of 5, plus any unfilled quota from previous sources.
|
|
// This ensures we show up to 15 total candidates, filling gaps when earlier sources have fewer.
|
|
// Pre-seed seen with the PR author since you cannot review your own PR.
|
|
seen := make(map[string]bool)
|
|
if authorLogin := result.Node.PullRequest.Author.Login; authorLogin != "" {
|
|
seen[authorLogin] = true
|
|
}
|
|
var candidates []ReviewerCandidate
|
|
const baseQuota = 5
|
|
|
|
// Suggested reviewers (excluding author)
|
|
suggestionsAdded := 0
|
|
for _, n := range result.Node.PullRequest.SuggestedActors.Nodes {
|
|
if suggestionsAdded >= baseQuota {
|
|
break
|
|
}
|
|
if n.IsAuthor {
|
|
continue
|
|
}
|
|
var candidate ReviewerCandidate
|
|
var login string
|
|
if n.Reviewer.TypeName == "User" && n.Reviewer.User.Login != "" {
|
|
login = n.Reviewer.User.Login
|
|
candidate = NewReviewerUser(login, n.Reviewer.User.Name)
|
|
} else if n.Reviewer.TypeName == "Bot" && n.Reviewer.Bot.Login != "" {
|
|
login = n.Reviewer.Bot.Login
|
|
candidate = NewReviewerBot(login)
|
|
} else {
|
|
continue
|
|
}
|
|
if !seen[login] {
|
|
seen[login] = true
|
|
candidates = append(candidates, candidate)
|
|
suggestionsAdded++
|
|
}
|
|
}
|
|
|
|
// Collaborators: quota = base + unfilled from suggestions
|
|
collaboratorsQuota := baseQuota + (baseQuota - suggestionsAdded)
|
|
collaboratorsAdded := 0
|
|
for _, c := range result.Repository.Collaborators.Nodes {
|
|
if collaboratorsAdded >= collaboratorsQuota {
|
|
break
|
|
}
|
|
if c.Login == "" {
|
|
continue
|
|
}
|
|
if !seen[c.Login] {
|
|
seen[c.Login] = true
|
|
candidates = append(candidates, NewReviewerUser(c.Login, c.Name))
|
|
collaboratorsAdded++
|
|
}
|
|
}
|
|
|
|
// Teams: quota = base + unfilled from collaborators
|
|
teamsQuota := baseQuota + (collaboratorsQuota - collaboratorsAdded)
|
|
teamsAdded := 0
|
|
ownerName := repo.RepoOwner()
|
|
for _, t := range result.Repository.Owner.Organization.Teams.Nodes {
|
|
if teamsAdded >= teamsQuota {
|
|
break
|
|
}
|
|
if t.Slug == "" {
|
|
continue
|
|
}
|
|
teamLogin := fmt.Sprintf("%s/%s", ownerName, t.Slug)
|
|
if !seen[teamLogin] {
|
|
seen[teamLogin] = true
|
|
candidates = append(candidates, NewReviewerTeam(ownerName, t.Slug))
|
|
teamsAdded++
|
|
}
|
|
}
|
|
|
|
// MoreResults uses unfiltered total counts (teams will be 0 for personal repos)
|
|
moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Repository.Owner.Organization.TeamsTotalCount.TotalCount
|
|
|
|
return candidates, moreResults, nil
|
|
}
|
|
|
|
// SuggestedReviewerActorsForRepo fetches potential reviewers for a repository.
|
|
// Unlike SuggestedReviewerActors, this doesn't require an existing PR - used for gh pr create.
|
|
// It combines results from two sources using a cascading quota system:
|
|
// - repository collaborators (base quota: 5)
|
|
// - organization teams (base quota: 5 + unfilled from collaborators)
|
|
//
|
|
// This ensures we show up to 10 total candidates, with each source filling any
|
|
// unfilled quota from the previous source. Results are deduplicated.
|
|
// Returns the candidates, a MoreResults count, and an error.
|
|
func SuggestedReviewerActorsForRepo(client *Client, repo ghrepo.Interface, query string) ([]ReviewerCandidate, int, error) {
|
|
type responseData struct {
|
|
Viewer struct {
|
|
Login string
|
|
}
|
|
Repository struct {
|
|
// HACK: There's no repo-level API to check Copilot reviewer eligibility,
|
|
// so we piggyback on an open PR's suggestedReviewerActors to detect
|
|
// whether Copilot is available as a reviewer for this repository.
|
|
PullRequests struct {
|
|
Nodes []struct {
|
|
SuggestedActors struct {
|
|
Nodes []struct {
|
|
Reviewer struct {
|
|
TypeName string `graphql:"__typename"`
|
|
Bot struct {
|
|
Login string
|
|
} `graphql:"... on Bot"`
|
|
}
|
|
}
|
|
} `graphql:"suggestedReviewerActors(first: 10)"`
|
|
}
|
|
} `graphql:"pullRequests(first: 1, states: [OPEN])"`
|
|
Owner struct {
|
|
TypeName string `graphql:"__typename"`
|
|
Organization struct {
|
|
Teams struct {
|
|
Nodes []struct {
|
|
Slug string
|
|
}
|
|
} `graphql:"teams(first: 10, query: $query)"`
|
|
TeamsTotalCount struct {
|
|
TotalCount int
|
|
} `graphql:"teamsTotalCount: teams(first: 0)"`
|
|
} `graphql:"... on Organization"`
|
|
}
|
|
Collaborators struct {
|
|
Nodes []struct {
|
|
Login string
|
|
Name string
|
|
}
|
|
} `graphql:"collaborators(first: 10, query: $query)"`
|
|
CollaboratorsTotalCount struct {
|
|
TotalCount int
|
|
} `graphql:"collaboratorsTotalCount: collaborators(first: 0)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}
|
|
|
|
variables := map[string]interface{}{
|
|
"query": githubv4.String(query),
|
|
"owner": githubv4.String(repo.RepoOwner()),
|
|
"name": githubv4.String(repo.RepoName()),
|
|
}
|
|
|
|
var result responseData
|
|
err := client.Query(repo.RepoHost(), "SuggestedReviewerActorsForRepo", &result, variables)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
// Build candidates using cascading quota logic.
|
|
// Pre-seed seen with the current user to exclude them from results
|
|
// since you cannot review your own PR.
|
|
seen := make(map[string]bool)
|
|
if result.Viewer.Login != "" {
|
|
seen[result.Viewer.Login] = true
|
|
}
|
|
var candidates []ReviewerCandidate
|
|
const baseQuota = 5
|
|
|
|
// Check for Copilot availability from open PR's suggested reviewers
|
|
for _, pr := range result.Repository.PullRequests.Nodes {
|
|
for _, actor := range pr.SuggestedActors.Nodes {
|
|
if actor.Reviewer.TypeName == "Bot" && actor.Reviewer.Bot.Login == CopilotReviewerLogin {
|
|
candidates = append(candidates, NewReviewerBot(CopilotReviewerLogin))
|
|
seen[CopilotReviewerLogin] = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collaborators
|
|
collaboratorsAdded := 0
|
|
for _, c := range result.Repository.Collaborators.Nodes {
|
|
if collaboratorsAdded >= baseQuota {
|
|
break
|
|
}
|
|
if c.Login == "" {
|
|
continue
|
|
}
|
|
if !seen[c.Login] {
|
|
seen[c.Login] = true
|
|
candidates = append(candidates, NewReviewerUser(c.Login, c.Name))
|
|
collaboratorsAdded++
|
|
}
|
|
}
|
|
|
|
// Teams: quota = base + unfilled from collaborators
|
|
teamsQuota := baseQuota + (baseQuota - collaboratorsAdded)
|
|
teamsAdded := 0
|
|
ownerName := repo.RepoOwner()
|
|
for _, t := range result.Repository.Owner.Organization.Teams.Nodes {
|
|
if teamsAdded >= teamsQuota {
|
|
break
|
|
}
|
|
if t.Slug == "" {
|
|
continue
|
|
}
|
|
teamLogin := fmt.Sprintf("%s/%s", ownerName, t.Slug)
|
|
if !seen[teamLogin] {
|
|
seen[teamLogin] = true
|
|
candidates = append(candidates, NewReviewerTeam(ownerName, t.Slug))
|
|
teamsAdded++
|
|
}
|
|
}
|
|
|
|
// MoreResults uses unfiltered total counts (teams will be 0 for personal repos)
|
|
moreResults := result.Repository.CollaboratorsTotalCount.TotalCount + result.Repository.Owner.Organization.TeamsTotalCount.TotalCount
|
|
|
|
return candidates, moreResults, nil
|
|
}
|
|
|
|
// extractTeamSlugs extracts just the slug portion from team identifiers.
|
|
// Team identifiers can be in "org/slug" format; this returns just the slug.
|
|
func extractTeamSlugs(teams []string) []string {
|
|
slugs := make([]string, 0, len(teams))
|
|
for _, t := range teams {
|
|
if t == "" {
|
|
continue
|
|
}
|
|
s := strings.SplitN(t, "/", 2)
|
|
slugs = append(slugs, s[len(s)-1])
|
|
}
|
|
return slugs
|
|
}
|
|
|
|
// toGitHubV4Strings converts a string slice to a githubv4.String slice,
|
|
// optionally appending a suffix to each element.
|
|
func toGitHubV4Strings(strs []string, suffix string) []githubv4.String {
|
|
result := make([]githubv4.String, len(strs))
|
|
for i, s := range strs {
|
|
result[i] = githubv4.String(s + suffix)
|
|
}
|
|
return result
|
|
}
|