@@ -3,7 +3,10 @@ package github
33import (
44 "context"
55 "encoding/json"
6+ "fmt"
67 "net/http"
8+ "net/http/httptest"
9+ "strconv"
710 "testing"
811 "time"
912
@@ -17,6 +20,78 @@ import (
1720 "github.com/stretchr/testify/require"
1821)
1922
23+ // recorderTransport routes HTTP requests through an in-process handler, mirroring
24+ // internal/githubv4mock's own transport. We need it because githubv4mock keys its
25+ // matchers by query string, so it cannot model a multi-page labels query: every
26+ // page issues the identical query and differs only by the $cursor variable. This
27+ // transport lets a single handler answer each page dynamically.
28+ type recorderTransport struct { handler http.Handler }
29+
30+ func (rt recorderTransport ) RoundTrip (req * http.Request ) (* http.Response , error ) {
31+ rec := httptest .NewRecorder ()
32+ rt .handler .ServeHTTP (rec , req )
33+ return rec .Result (), nil
34+ }
35+
36+ // alwaysHasNextPageLabelsClient returns a GraphQL client whose labels query always
37+ // reports another page, advancing the cursor on each call. It exercises uiGetLabels'
38+ // page cap: the loop fetches one label per page until it stops at uiGetMaxPages with
39+ // has_more=true. totalCount is reported as a large server-side count so the test can
40+ // confirm it stays the full repo count even when results are truncated.
41+ func alwaysHasNextPageLabelsClient (t * testing.T ) * http.Client {
42+ t .Helper ()
43+ var calls int
44+ mux := http .NewServeMux ()
45+ mux .HandleFunc ("/graphql" , func (w http.ResponseWriter , _ * http.Request ) {
46+ calls ++
47+ resp := map [string ]any {
48+ "data" : map [string ]any {
49+ "repository" : map [string ]any {
50+ "labels" : map [string ]any {
51+ "nodes" : []any {
52+ map [string ]any {
53+ "id" : fmt .Sprintf ("label-%d" , calls ),
54+ "name" : fmt .Sprintf ("label-%d" , calls ),
55+ "color" : "ededed" ,
56+ "description" : "" ,
57+ },
58+ },
59+ "totalCount" : 9999 ,
60+ "pageInfo" : map [string ]any {
61+ "hasNextPage" : true ,
62+ "endCursor" : fmt .Sprintf ("cursor-%d" , calls ),
63+ },
64+ },
65+ },
66+ },
67+ }
68+ w .Header ().Set ("Content-Type" , "application/json" )
69+ _ = json .NewEncoder (w ).Encode (resp )
70+ })
71+ return & http.Client {Transport : recorderTransport {handler : mux }}
72+ }
73+
74+ // alwaysNextPageHandler returns a REST handler that always advertises another page
75+ // via the Link header, regardless of the page requested. It drives a pagination loop
76+ // purely off the page cap so tests can assert ui_get stops at uiGetMaxPages and sets
77+ // has_more=true. The same body is returned for every page, so the number of items
78+ // collected equals the number of pages fetched.
79+ func alwaysNextPageHandler (t * testing.T , body any ) http.HandlerFunc {
80+ t .Helper ()
81+ return func (w http.ResponseWriter , r * http.Request ) {
82+ page := 1
83+ if p := r .URL .Query ().Get ("page" ); p != "" {
84+ if parsed , err := strconv .Atoi (p ); err == nil {
85+ page = parsed
86+ }
87+ }
88+ w .Header ().Set ("Link" , fmt .Sprintf (`<https://api.github.com/next?page=%d>; rel="next"` , page + 1 ))
89+ w .Header ().Set ("Content-Type" , "application/json" )
90+ w .WriteHeader (http .StatusOK )
91+ _ = json .NewEncoder (w ).Encode (body )
92+ }
93+ }
94+
2095func Test_UIGet (t * testing.T ) {
2196 // Verify tool definition
2297 serverTool := UIGet (translations .NullTranslationHelper )
@@ -95,6 +170,7 @@ func Test_UIGet(t *testing.T) {
95170 require .NoError (t , json .Unmarshal ([]byte (responseText ), & response ))
96171 assert .Contains (t , response , "assignees" )
97172 assert .Contains (t , response , "totalCount" )
173+ assert .Equal (t , false , response ["has_more" ], "results within the page cap should not be truncated" )
98174 },
99175 },
100176 {
@@ -113,6 +189,7 @@ func Test_UIGet(t *testing.T) {
113189 require .NoError (t , json .Unmarshal ([]byte (responseText ), & response ))
114190 assert .Contains (t , response , "branches" )
115191 assert .Contains (t , response , "totalCount" )
192+ assert .Equal (t , false , response ["has_more" ], "results within the page cap should not be truncated" )
116193 },
117194 },
118195 {
@@ -228,6 +305,7 @@ func Test_UIGet(t *testing.T) {
228305 require .Len (t , labels , 1 )
229306 assert .Equal (t , "bug" , labels [0 ].(map [string ]any )["name" ])
230307 assert .Equal (t , float64 (1 ), response ["totalCount" ])
308+ assert .Equal (t , false , response ["has_more" ], "results within the page cap should not be truncated" )
231309 },
232310 },
233311 {
@@ -300,6 +378,70 @@ func Test_UIGet(t *testing.T) {
300378 assert .Equal (t , "docs" , teams [0 ].(map [string ]any )["slug" ])
301379 assert .Equal (t , "owner" , teams [0 ].(map [string ]any )["org" ])
302380 assert .Equal (t , float64 (2 ), response ["totalCount" ])
381+ assert .Equal (t , false , response ["has_more" ], "results within the page cap should not be truncated" )
382+ },
383+ },
384+ {
385+ name : "branches pagination stops at the page cap" ,
386+ mockedClient : MockHTTPClientWithHandlers (map [string ]http.HandlerFunc {
387+ "GET /repos/owner/repo/branches" : alwaysNextPageHandler (t , []* github.Branch {{Name : github .Ptr ("feature" )}}),
388+ }),
389+ requestArgs : map [string ]any {
390+ "method" : "branches" ,
391+ "owner" : "owner" ,
392+ "repo" : "repo" ,
393+ },
394+ expectError : false ,
395+ validateResult : func (t * testing.T , responseText string ) {
396+ var response map [string ]any
397+ require .NoError (t , json .Unmarshal ([]byte (responseText ), & response ))
398+ branches , ok := response ["branches" ].([]any )
399+ require .True (t , ok , "branches should be a list" )
400+ assert .Len (t , branches , uiGetMaxPages , "loop should stop at the page cap" )
401+ assert .Equal (t , float64 (uiGetMaxPages ), response ["totalCount" ], "totalCount should be the bounded count" )
402+ assert .Equal (t , true , response ["has_more" ], "truncated results should set has_more" )
403+ },
404+ },
405+ {
406+ name : "labels pagination stops at the page cap" ,
407+ mockedGQLClient : alwaysHasNextPageLabelsClient (t ),
408+ requestArgs : map [string ]any {
409+ "method" : "labels" ,
410+ "owner" : "owner" ,
411+ "repo" : "repo" ,
412+ },
413+ expectError : false ,
414+ validateResult : func (t * testing.T , responseText string ) {
415+ var response map [string ]any
416+ require .NoError (t , json .Unmarshal ([]byte (responseText ), & response ))
417+ labels , ok := response ["labels" ].([]any )
418+ require .True (t , ok , "labels should be a list" )
419+ assert .Len (t , labels , uiGetMaxPages , "loop should stop at the page cap" )
420+ assert .Equal (t , true , response ["has_more" ], "truncated results should set has_more" )
421+ // totalCount stays the server-reported full count, so it can exceed
422+ // the number of labels returned once results are truncated.
423+ assert .Equal (t , float64 (9999 ), response ["totalCount" ])
424+ },
425+ },
426+ {
427+ name : "reviewers pagination stops at the page cap" ,
428+ mockedClient : MockHTTPClientWithHandlers (map [string ]http.HandlerFunc {
429+ "GET /repos/owner/repo/collaborators" : alwaysNextPageHandler (t , []* github.User {{Login : github .Ptr ("octocat" )}}),
430+ "GET /repos/owner/repo/teams" : mockResponse (t , http .StatusOK , mockReviewerTeams ),
431+ }),
432+ requestArgs : map [string ]any {
433+ "method" : "reviewers" ,
434+ "owner" : "owner" ,
435+ "repo" : "repo" ,
436+ },
437+ expectError : false ,
438+ validateResult : func (t * testing.T , responseText string ) {
439+ var response map [string ]any
440+ require .NoError (t , json .Unmarshal ([]byte (responseText ), & response ))
441+ users , ok := response ["users" ].([]any )
442+ require .True (t , ok , "users should be a list" )
443+ assert .Len (t , users , uiGetMaxPages , "collaborators loop should stop at the page cap" )
444+ assert .Equal (t , true , response ["has_more" ], "truncating either loop should set has_more" )
303445 },
304446 },
305447 {
0 commit comments