@@ -791,16 +791,30 @@ public function listBranches(string $owner, string $repositoryName, int $perPage
791791 /**
792792 * @return array{items: array<string>, hasNext: bool, nextCursor: string|null}
793793 */
794+ /**
795+ * Fetches one logical page of prefix-matching branches.
796+ *
797+ * GitHub's GraphQL query parameter does substring matching, so we request edges
798+ * (which carry per-item cursors) and apply str_starts_with client-side. We collect
799+ * up to $perPage + 1 matching edges across as many GitHub API pages as needed:
800+ * - If we find the +1 probe item, hasNext=true and nextCursor points to the cursor
801+ * of the last returned item, so the next call resumes exactly where we stopped.
802+ * - If GitHub is exhausted before the probe, hasNext=false.
803+ * This ensures items is never empty while hasNext is true.
804+ *
805+ * @return array{items: array<string>, hasNext: bool, nextCursor: string|null}
806+ */
794807 private function listBranchesPage (string $ owner , string $ repositoryName , int $ perPage , ?string $ cursor , string $ search ): array
795808 {
796- // refPrefix must be a complete path namespace (e.g. "refs/heads/"); the separate
797- // query param handles prefix filtering on branch names.
798- $ query = <<<'GRAPHQL'
809+ $ gql = <<<'GRAPHQL'
799810query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String, $query: String) {
800811 repository(owner: $owner, name: $name) {
801812 refs(refPrefix: "refs/heads/", first: $first, after: $after, orderBy: {field: ALPHABETICAL, direction: ASC}, query: $query) {
802- nodes {
803- name
813+ edges {
814+ cursor
815+ node {
816+ name
817+ }
804818 }
805819 pageInfo {
806820 hasNextPage
@@ -811,51 +825,69 @@ private function listBranchesPage(string $owner, string $repositoryName, int $pe
811825}
812826GRAPHQL;
813827
814- $ response = $ this ->call (self ::METHOD_POST , '/graphql ' , ['Authorization ' => "Bearer $ this ->accessToken " ], [
815- 'query ' => $ query ,
816- 'variables ' => [
817- 'owner ' => $ owner ,
818- 'name ' => $ repositoryName ,
819- 'first ' => $ perPage ,
820- 'after ' => $ cursor ,
821- 'query ' => $ search !== '' ? $ search : null ,
822- ],
823- ]);
828+ /** @var array<array{name: string, cursor: string}> $collected */
829+ $ collected = [];
830+ $ currentCursor = $ cursor ;
824831
825- $ statusCode = $ response ['headers ' ]['status-code ' ] ?? 0 ;
826- $ responseBody = $ response ['body ' ] ?? [];
832+ do {
833+ $ response = $ this ->call (self ::METHOD_POST , '/graphql ' , ['Authorization ' => "Bearer $ this ->accessToken " ], [
834+ 'query ' => $ gql ,
835+ 'variables ' => [
836+ 'owner ' => $ owner ,
837+ 'name ' => $ repositoryName ,
838+ 'first ' => $ perPage ,
839+ 'after ' => $ currentCursor ,
840+ 'query ' => $ search !== '' ? $ search : null ,
841+ ],
842+ ]);
827843
828- if ($ statusCode < 200 || $ statusCode >= 300 || !is_array ($ responseBody ) || array_key_exists ('errors ' , $ responseBody )) {
829- return [
830- 'items ' => [],
831- 'hasNext ' => false ,
832- 'nextCursor ' => null ,
833- ];
834- }
844+ $ statusCode = $ response ['headers ' ]['status-code ' ] ?? 0 ;
845+ $ responseBody = $ response ['body ' ] ?? [];
846+
847+ if ($ statusCode < 200 || $ statusCode >= 300 || !is_array ($ responseBody ) || array_key_exists ('errors ' , $ responseBody )) {
848+ return ['items ' => [], 'hasNext ' => false , 'nextCursor ' => null ];
849+ }
835850
836- $ refs = $ responseBody ['data ' ]['repository ' ]['refs ' ] ?? null ;
851+ $ refs = $ responseBody ['data ' ]['repository ' ]['refs ' ] ?? null ;
837852
838- if (!is_array ($ refs )) {
839- return [
840- 'items ' => [],
841- 'hasNext ' => false ,
842- 'nextCursor ' => null ,
843- ];
844- }
853+ if (!is_array ($ refs )) {
854+ return ['items ' => [], 'hasNext ' => false , 'nextCursor ' => null ];
855+ }
845856
846- $ pageInfo = $ refs ['pageInfo ' ] ?? [];
847- $ hasNext = $ pageInfo ['hasNextPage ' ] ?? false ;
857+ $ pageInfo = $ refs ['pageInfo ' ] ?? [];
858+ $ hasNextPage = (bool ) ($ pageInfo ['hasNextPage ' ] ?? false );
859+ $ currentCursor = $ pageInfo ['endCursor ' ] ?? null ;
860+
861+ $ probeFound = false ;
862+ foreach ($ refs ['edges ' ] ?? [] as $ edge ) {
863+ $ name = $ edge ['node ' ]['name ' ] ?? '' ;
864+ if ($ search === '' || str_starts_with ($ name , $ search )) {
865+ $ collected [] = ['name ' => $ name , 'cursor ' => $ edge ['cursor ' ] ?? '' ];
866+ if (count ($ collected ) > $ perPage ) {
867+ $ probeFound = true ;
868+ break ;
869+ }
870+ }
871+ }
848872
849- // GitHub's query param does substring matching; post-filter to enforce prefix semantics.
850- $ names = array_map (fn ($ branch ) => $ branch ['name ' ] ?? '' , $ refs ['nodes ' ] ?? []);
851- if ($ search !== '' ) {
852- $ names = array_values (array_filter ($ names , fn ($ name ) => str_starts_with ($ name , $ search )));
873+ if ($ probeFound ) {
874+ break ;
875+ }
876+ } while ($ hasNextPage );
877+
878+ if (count ($ collected ) > $ perPage ) {
879+ $ toReturn = array_slice ($ collected , 0 , $ perPage );
880+ return [
881+ 'items ' => array_column ($ toReturn , 'name ' ),
882+ 'hasNext ' => true ,
883+ 'nextCursor ' => $ toReturn [$ perPage - 1 ]['cursor ' ],
884+ ];
853885 }
854886
855887 return [
856- 'items ' => array_values ( $ names ),
857- 'hasNext ' => $ hasNext ,
858- 'nextCursor ' => $ hasNext ? ( $ pageInfo [ ' endCursor ' ] ?? null ) : null ,
888+ 'items ' => array_column ( $ collected , ' name ' ),
889+ 'hasNext ' => false ,
890+ 'nextCursor ' => null ,
859891 ];
860892 }
861893
0 commit comments