Move PR review queries from queries_pr.go to queries_pr_review.go

Consolidate all review-related types, methods, and functions into
queries_pr_review.go for better code organization. The file is now
ordered by logical sections: review data types, review status, review
requests, reviewer candidates, API operations, and helpers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Kynan Ware 2026-02-13 13:40:59 -07:00
parent 1d730951d2
commit 72a6e9f3a7
2 changed files with 597 additions and 595 deletions

View file

@ -1,12 +1,9 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/cli/cli/v2/internal/ghrepo"
@ -301,65 +298,6 @@ type PullRequestFile struct {
Deletions int `json:"deletions"`
}
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
}
func (pr PullRequest) HeadLabel() string {
if pr.IsCrossRepository {
return fmt.Sprintf("%s:%s", pr.HeadRepositoryOwner.Login, pr.HeadRefName)
@ -383,25 +321,6 @@ func (pr PullRequest) IsOpen() bool {
return pr.State == "OPEN"
}
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
}
type PullRequestChecksStatus struct {
Pending int
Failing int
@ -541,18 +460,6 @@ func parseCheckStatusFromCheckConclusionState(state CheckConclusionState) checkS
}
}
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)}
}
// CreatePullRequest creates a pull request in a GitHub repository
func CreatePullRequest(client *Client, repo *Repository, params map[string]interface{}) (*PullRequest, error) {
query := `
@ -671,145 +578,6 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
return pr, 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
}
// 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)
}
// SuggestedAssignableActors fetches up to 10 suggested actors for a specific assignable
// (Issue or PullRequest) node ID. `assignableID` is the GraphQL node ID for the Issue/PR.
// Returns the actors, the total count of available assignees in the repo, and an error.
@ -902,342 +670,6 @@ func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignable
return actors, availableAssigneesCount, nil
}
// ReviewerCandidate represents a potential reviewer for a pull request.
// This can be a User, Bot, or Team.
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() {}
// 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.
// Use a single query that includes organization.teams - if the owner is not an org,
// we'll get a "Could not resolve to an Organization" error which we handle gracefully.
// We also fetch unfiltered total counts via aliases for the "X more" display.
type responseData struct {
Node struct {
PullRequest struct {
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 {
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)"`
Organization struct {
Teams struct {
Nodes []struct {
Slug string
}
} `graphql:"teams(first: 10, query: $query)"`
TeamsTotalCount struct {
TotalCount int
} `graphql:"teamsTotalCount: teams(first: 0)"`
} `graphql:"organization(login: $owner)"`
}
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)
// Handle the case where the owner is not an organization - the query still returns
// partial data (repository, node), so we can continue processing.
if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) {
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.
seen := make(map[string]bool)
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.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.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 {
Repository struct {
// Check for Copilot availability by looking at any open PR's suggested reviewers
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])"`
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)"`
Organization struct {
Teams struct {
Nodes []struct {
Slug string
}
} `graphql:"teams(first: 10, query: $query)"`
TeamsTotalCount struct {
TotalCount int
} `graphql:"teamsTotalCount: teams(first: 0)"`
} `graphql:"organization(login: $owner)"`
}
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)
// Handle the case where the owner is not an organization - the query still returns
// partial data (repository), so we can continue processing.
if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) {
return nil, 0, err
}
// Build candidates using cascading quota logic
seen := make(map[string]bool)
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.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.Organization.TeamsTotalCount.TotalCount
return candidates, moreResults, nil
}
func UpdatePullRequestBranch(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestBranchInput) error {
var mutation struct {
UpdatePullRequestBranch struct {

View file

@ -1,6 +1,11 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"github.com/cli/cli/v2/internal/ghrepo"
@ -42,33 +47,6 @@ type PullRequestReview struct {
Commit Commit `json:"commit"`
}
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)
}
func (prr PullRequestReview) Identifier() string {
return prr.ID
}
@ -115,3 +93,595 @@ func (prr PullRequestReview) Reactions() 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.
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.
// Use a single query that includes organization.teams - if the owner is not an org,
// we'll get a "Could not resolve to an Organization" error which we handle gracefully.
// We also fetch unfiltered total counts via aliases for the "X more" display.
type responseData struct {
Node struct {
PullRequest struct {
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 {
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)"`
Organization struct {
Teams struct {
Nodes []struct {
Slug string
}
} `graphql:"teams(first: 10, query: $query)"`
TeamsTotalCount struct {
TotalCount int
} `graphql:"teamsTotalCount: teams(first: 0)"`
} `graphql:"organization(login: $owner)"`
}
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)
// Handle the case where the owner is not an organization - the query still returns
// partial data (repository, node), so we can continue processing.
if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) {
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.
seen := make(map[string]bool)
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.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.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 {
Repository struct {
// Check for Copilot availability by looking at any open PR's suggested reviewers
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])"`
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)"`
Organization struct {
Teams struct {
Nodes []struct {
Slug string
}
} `graphql:"teams(first: 10, query: $query)"`
TeamsTotalCount struct {
TotalCount int
} `graphql:"teamsTotalCount: teams(first: 0)"`
} `graphql:"organization(login: $owner)"`
}
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)
// Handle the case where the owner is not an organization - the query still returns
// partial data (repository), so we can continue processing.
if err != nil && !strings.Contains(err.Error(), errorResolvingOrganization) {
return nil, 0, err
}
// Build candidates using cascading quota logic
seen := make(map[string]bool)
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.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.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
}