diff --git a/api/queries_pr.go b/api/queries_pr.go index 2ee1d5e5f..f1d383971 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -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 { diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index d5565b54b..b233ee46f 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -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 +}