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 unfiltered 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": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [%s]}}, "repository": { "owner": {"__typename": "Organization", "teams": {"nodes": [%s]}, "teamsTotalCount": {"totalCount": %d}}, "collaborators": {"nodes": [%s]}, "collaboratorsTotalCount": {"totalCount": %d} } } }`, strings.Join(suggestionNodes, ","), strings.Join(teamNodes, ","), totalTeams, strings.Join(collabNodes, ","), totalCollabs) } 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": {"author": {"login": "testauthor"}, "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": { "owner": {"__typename": "Organization", "teams": {"nodes": [{"slug": "team1"}]}, "teamsTotalCount": {"totalCount": 3}}, "collaborators": {"nodes": [{"login": "c1", "name": "C1"}]}, "collaboratorsTotalCount": {"totalCount": 5} } } }`)) }, expectedCount: 4, expectedLogins: []string{"s1", "s2", "c1", "OWNER/team1"}, expectedMore: 8, }, { name: "author excluded from collaborators", httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query SuggestedReviewerActors\b`), httpmock.StringResponse(`{ "data": { "node": {"author": {"login": "theauthor"}, "suggestedReviewerActors": {"nodes": [ {"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}} ]}}, "repository": { "collaborators": {"nodes": [ {"login": "theauthor", "name": "The Author"}, {"login": "c1", "name": "C1"} ]}, "collaboratorsTotalCount": {"totalCount": 5} } } }`)) }, expectedCount: 2, expectedLogins: []string{"s1", "c1"}, expectedMore: 5, }, { 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": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [ {"isAuthor": false, "reviewer": {"__typename": "User", "login": "shareduser", "name": "Shared"}} ]}}, "repository": { "owner": {"__typename": "Organization", "teams": {"nodes": [{"slug": "team1"}]}, "teamsTotalCount": {"totalCount": 5}}, "collaborators": {"nodes": [ {"login": "shareduser", "name": "Shared"}, {"login": "c1", "name": "C1"} ]}, "collaboratorsTotalCount": {"totalCount": 10} } } }`)) }, 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": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [ {"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}} ]}}, "repository": { "owner": {"__typename": "User"}, "collaborators": {"nodes": [{"login": "c1", "name": "C1"}]}, "collaboratorsTotalCount": {"totalCount": 3} } } }`)) }, 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": {"author": {"login": "testauthor"}, "suggestedReviewerActors": {"nodes": [ {"isAuthor": false, "reviewer": {"__typename": "Bot", "login": "copilot-pull-request-reviewer"}}, {"isAuthor": false, "reviewer": {"__typename": "User", "login": "s1", "name": "S1"}} ]}}, "repository": { "owner": {"__typename": "Organization", "teams": {"nodes": []}, "teamsTotalCount": {"totalCount": 0}}, "collaborators": {"nodes": []}, "collaboratorsTotalCount": {"totalCount": 5} } } }`)) }, 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) }) } } // mockReviewerResponseForRepo generates a GraphQL response for SuggestedReviewerActorsForRepo tests. // It creates collaborators (c1, c2...) and teams (team1, team2...). func mockReviewerResponseForRepo(collabs, teams, totalCollabs, totalTeams int) string { return mockReviewerResponseForRepoWithCopilot(collabs, teams, totalCollabs, totalTeams, false) } // mockReviewerResponseForRepoWithCopilot generates a GraphQL response for SuggestedReviewerActorsForRepo tests. // If copilotAvailable is true, includes Copilot in the first open PR's suggested reviewers. func mockReviewerResponseForRepoWithCopilot(collabs, teams, totalCollabs, totalTeams int, copilotAvailable bool) string { var collabNodes, teamNodes []string 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)) } pullRequestsJSON := `"pullRequests": {"nodes": []}` if copilotAvailable { pullRequestsJSON = `"pullRequests": {"nodes": [{"suggestedReviewerActors": {"nodes": [{"reviewer": {"__typename": "Bot", "login": "copilot-pull-request-reviewer"}}]}}]}` } return fmt.Sprintf(`{ "data": { "viewer": {"login": "testuser"}, "repository": { %s, "owner": {"__typename": "Organization", "teams": {"nodes": [%s]}, "teamsTotalCount": {"totalCount": %d}}, "collaborators": {"nodes": [%s]}, "collaboratorsTotalCount": {"totalCount": %d} } } }`, pullRequestsJSON, strings.Join(teamNodes, ","), totalTeams, strings.Join(collabNodes, ","), totalCollabs) } func TestSuggestedReviewerActorsForRepo(t *testing.T) { tests := []struct { name string httpStubs func(*httpmock.Registry) expectedCount int expectedLogins []string expectedMore int expectError bool }{ { name: "both sources plentiful - 5 each from cascading quota", httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), httpmock.StringResponse(mockReviewerResponseForRepo(6, 6, 20, 10))) }, expectedCount: 10, expectedLogins: []string{"c1", "c2", "c3", "c4", "c5", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5"}, expectedMore: 30, }, { name: "few collaborators - teams fill gap", httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), httpmock.StringResponse(mockReviewerResponseForRepo(2, 10, 2, 15))) }, expectedCount: 10, expectedLogins: []string{"c1", "c2", "OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5", "OWNER/team6", "OWNER/team7", "OWNER/team8"}, expectedMore: 17, }, { name: "no collaborators - teams only", httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), httpmock.StringResponse(mockReviewerResponseForRepo(0, 10, 0, 20))) }, expectedCount: 10, expectedLogins: []string{"OWNER/team1", "OWNER/team2", "OWNER/team3", "OWNER/team4", "OWNER/team5", "OWNER/team6", "OWNER/team7", "OWNER/team8", "OWNER/team9", "OWNER/team10"}, expectedMore: 20, }, { name: "personal repo - no organization teams", httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), httpmock.StringResponse(`{ "data": { "repository": { "pullRequests": {"nodes": []}, "owner": {"__typename": "User"}, "collaborators": {"nodes": [{"login": "c1", "name": "C1"}]}, "collaboratorsTotalCount": {"totalCount": 3} } } }`)) }, expectedCount: 1, expectedLogins: []string{"c1"}, expectedMore: 3, }, { name: "empty repo", httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), httpmock.StringResponse(mockReviewerResponseForRepo(0, 0, 0, 0))) }, expectedCount: 0, expectedLogins: []string{}, expectedMore: 0, }, { name: "copilot available - prepended to candidates", httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), httpmock.StringResponse(mockReviewerResponseForRepoWithCopilot(3, 2, 5, 5, true))) }, expectedCount: 6, expectedLogins: []string{"copilot-pull-request-reviewer", "c1", "c2", "c3", "OWNER/team1", "OWNER/team2"}, expectedMore: 10, }, { name: "copilot not available - not included", httpStubs: func(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), httpmock.StringResponse(mockReviewerResponseForRepoWithCopilot(3, 2, 5, 5, false))) }, expectedCount: 5, expectedLogins: []string{"c1", "c2", "c3", "OWNER/team1", "OWNER/team2"}, expectedMore: 10, }, { name: "viewer excluded from collaborators", httpStubs: func(reg *httpmock.Registry) { // c1 matches the viewer login "testuser" won't be in this fixture, // but we can craft a response where the viewer login matches a collaborator. reg.Register( httpmock.GraphQL(`query SuggestedReviewerActorsForRepo\b`), httpmock.StringResponse(`{ "data": { "viewer": {"login": "c2"}, "repository": { "pullRequests": {"nodes": []}, "owner": {"__typename": "Organization", "teams": {"nodes": []}, "teamsTotalCount": {"totalCount": 0}}, "collaborators": {"nodes": [ {"login": "c1", "name": "C1"}, {"login": "c2", "name": "C2"}, {"login": "c3", "name": "C3"} ]}, "collaboratorsTotalCount": {"totalCount": 3} } } }`)) }, expectedCount: 2, expectedLogins: []string{"c1", "c3"}, expectedMore: 3, }, } 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 := SuggestedReviewerActorsForRepo(client, repo, "") 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) }) } }