cli/api/queries_pr_test.go
Kynan Ware 2d191e5ba0 Implement cascading quota for reviewer suggestions
Each source (suggestions, collaborators, teams) has base quota of 5.
Unfilled slots cascade to later sources, allowing up to 15 total.
Adds unit tests with HTTP mocks.
2026-01-29 11:30:08 -07:00

337 lines
10 KiB
Go

package api
import (
"encoding/json"
"fmt"
"strings"
"testing"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
)
func TestBranchDeleteRemote(t *testing.T) {
var tests = []struct {
name string
branch string
httpStubs func(*httpmock.Registry)
expectError bool
}{
{
name: "success",
branch: "owner/branch#123",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/owner%2Fbranch%23123"),
httpmock.StatusStringResponse(204, ""))
},
expectError: false,
},
{
name: "error",
branch: "my-branch",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/my-branch"),
httpmock.StatusStringResponse(500, `{"message": "oh no"}`))
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
http := &httpmock.Registry{}
if tt.httpStubs != nil {
tt.httpStubs(http)
}
client := newTestClient(http)
repo, _ := ghrepo.FromFullName("OWNER/REPO")
err := BranchDeleteRemote(client, repo, tt.branch)
if (err != nil) != tt.expectError {
t.Fatalf("unexpected result: %v", err)
}
})
}
}
func Test_Logins(t *testing.T) {
rr := ReviewRequests{}
var tests = []struct {
name string
requestedReviews string
want []string
}{
{
name: "no requested reviewers",
requestedReviews: `{"nodes": []}`,
want: []string{},
},
{
name: "user",
requestedReviews: `{"nodes": [
{
"requestedreviewer": {
"__typename": "User", "login": "testuser"
}
}
]}`,
want: []string{"testuser"},
},
{
name: "team",
requestedReviews: `{"nodes": [
{
"requestedreviewer": {
"__typename": "Team",
"name": "Test Team",
"slug": "test-team",
"organization": {"login": "myorg"}
}
}
]}`,
want: []string{"myorg/test-team"},
},
{
name: "multiple users and teams",
requestedReviews: `{"nodes": [
{
"requestedreviewer": {
"__typename": "User", "login": "user1"
}
},
{
"requestedreviewer": {
"__typename": "User", "login": "user2"
}
},
{
"requestedreviewer": {
"__typename": "Team",
"name": "Test Team",
"slug": "test-team",
"organization": {"login": "myorg"}
}
},
{
"requestedreviewer": {
"__typename": "Team",
"name": "Dev Team",
"slug": "dev-team",
"organization": {"login": "myorg"}
}
}
]}`,
want: []string{"user1", "user2", "myorg/test-team", "myorg/dev-team"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := json.Unmarshal([]byte(tt.requestedReviews), &rr)
assert.NoError(t, err, "Failed to unmarshal json string as ReviewRequests")
logins := rr.Logins()
assert.Equal(t, tt.want, logins)
})
}
}
// mockReviewerResponse generates a GraphQL response for SuggestedReviewerActors tests.
// It creates suggestions (s1, s2...), collaborators (c1, c2...), and teams (team1, team2...).
// totalCollabs and totalTeams set the TotalCount fields (for "more results" calculation).
func mockReviewerResponse(suggestions, collabs, teams, totalCollabs, totalTeams int) string {
var suggestionNodes, collabNodes, teamNodes []string
for i := 1; i <= suggestions; i++ {
suggestionNodes = append(suggestionNodes,
fmt.Sprintf(`{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s%d", "name": "S%d"}}`, i, i))
}
for i := 1; i <= collabs; i++ {
collabNodes = append(collabNodes,
fmt.Sprintf(`{"login": "c%d", "name": "C%d"}`, i, i))
}
for i := 1; i <= teams; i++ {
teamNodes = append(teamNodes,
fmt.Sprintf(`{"slug": "team%d"}`, i))
}
return fmt.Sprintf(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [%s]}},
"repository": {"collaborators": {"totalCount": %d, "nodes": [%s]}},
"organization": {"teams": {"totalCount": %d, "nodes": [%s]}}
}
}`, strings.Join(suggestionNodes, ","), totalCollabs, strings.Join(collabNodes, ","),
totalTeams, strings.Join(teamNodes, ","))
}
func TestSuggestedReviewerActors(t *testing.T) {
tests := []struct {
name string
httpStubs func(*httpmock.Registry)
expectedCount int
expectedLogins []string
expectedMore int
expectError bool
}{
{
name: "all sources plentiful - 5 each from cascading quota",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(mockReviewerResponse(6, 6, 6, 20, 10)))
},
expectedCount: 15,
expectedLogins: []string{"s1", "s2", "s3", "s4", "s5", "c1", "c2", "c3", "c4", "c5", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5"},
expectedMore: 30,
},
{
name: "few suggestions - collaborators fill gap",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(mockReviewerResponse(2, 10, 6, 50, 10)))
},
expectedCount: 15,
expectedLogins: []string{"s1", "s2", "c1", "c2", "c3", "c4", "c5", "c6", "c7", "c8", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5"},
expectedMore: 60,
},
{
name: "few suggestions and collaborators - teams fill gap",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(mockReviewerResponse(2, 3, 10, 3, 10)))
},
expectedCount: 15,
expectedLogins: []string{"s1", "s2", "c1", "c2", "c3", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5", "OWNER/team6", "OWNER/team7", "OWNER/team8", "OWNER/team9", "OWNER/team10"},
expectedMore: 13,
},
{
name: "no suggestions or collaborators - teams only",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(mockReviewerResponse(0, 0, 10, 0, 10)))
},
expectedCount: 10, // max 15, but only 10 teams available
expectedLogins: []string{"OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5", "OWNER/team6", "OWNER/team7", "OWNER/team8", "OWNER/team9", "OWNER/team10"},
expectedMore: 10,
},
{
name: "author excluded from suggestions",
httpStubs: func(reg *httpmock.Registry) {
// Custom response with author flag
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [
{"isAuthor": true, "reviewer": {"__typename": "User", "login": "author", "name": "Author"}},
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}},
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s2", "name": "S2"}}
]}},
"repository": {"collaborators": {"totalCount": 5, "nodes": [{"login": "c1", "name": "C1"}]}},
"organization": {"teams": {"totalCount": 3, "nodes": [{"slug": "team1"}]}}
}
}`))
},
expectedCount: 4,
expectedLogins: []string{"s1", "s2", "c1", "OWNER/team1"},
expectedMore: 8,
},
{
name: "deduplication across sources",
httpStubs: func(reg *httpmock.Registry) {
// Custom response with duplicate user
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "shareduser", "name": "Shared"}}
]}},
"repository": {"collaborators": {"totalCount": 10, "nodes": [
{"login": "shareduser", "name": "Shared"},
{"login": "c1", "name": "C1"}
]}},
"organization": {"teams": {"totalCount": 5, "nodes": [{"slug": "team1"}]}}
}
}`))
},
expectedCount: 3,
expectedLogins: []string{"shareduser", "c1", "OWNER/team1"},
expectedMore: 15,
},
{
name: "personal repo - no organization teams",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}}
]}},
"repository": {"collaborators": {"totalCount": 3, "nodes": [{"login": "c1", "name": "C1"}]}},
"organization": null
},
"errors": [{"message": "Could not resolve to an Organization with the login of 'OWNER'."}]
}`))
},
expectedCount: 2,
expectedLogins: []string{"s1", "c1"},
expectedMore: 3,
},
{
name: "bot reviewer included",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query SuggestedReviewerActors\b`),
httpmock.StringResponse(`{
"data": {
"node": {"suggestedReviewerActors": {"nodes": [
{"isAuthor": false, "reviewer": {"__typename": "Bot", "login": "copilot-pull-request-reviewer"}},
{"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}}
]}},
"repository": {"collaborators": {"totalCount": 5, "nodes": []}},
"organization": {"teams": {"totalCount": 0, "nodes": []}}
}
}`))
},
expectedCount: 2,
expectedLogins: []string{"copilot-pull-request-reviewer", "s1"},
expectedMore: 5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
if tt.httpStubs != nil {
tt.httpStubs(reg)
}
client := newTestClient(reg)
repo, _ := ghrepo.FromFullName("OWNER/REPO")
candidates, moreResults, err := SuggestedReviewerActors(client, repo, "PR_123", "")
if tt.expectError {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.expectedCount, len(candidates), "candidate count mismatch")
assert.Equal(t, tt.expectedMore, moreResults, "moreResults mismatch")
logins := make([]string, len(candidates))
for i, c := range candidates {
logins[i] = c.Login()
}
assert.Equal(t, tt.expectedLogins, logins)
})
}
}