@@ -742,32 +742,126 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName,
742742 }
743743
744744 /**
745- * Lists branches for a given repository
745+ * Lists branches using GitHub GraphQL repository.refs with prefix search and cursor pagination.
746746 *
747- * @param string $owner Owner name of the repository
748- * @param string $repositoryName Name of the GitHub repository
749- * @param int $perPage Number of branches to fetch per page
750- * @param int $page Page number to start fetching from
751- * @return array<string> List of branch names as array
747+ * Search matches branch names by prefix only ('feat' → 'feature-x', not 'my-feature').
748+ * Pass an integer $page to walk forward page-by-page (each step costs one extra GraphQL call
749+ * to resolve the cursor chain); pass a cursor string from a previous nextCursor to jump
750+ * directly. perPage is clamped to [1, 100].
751+ *
752+ * @param string $owner
753+ * @param string $repositoryName
754+ * @param int $perPage Clamped to [1, 100]
755+ * @param int|string|null $page Pass 1 (or null) for the first page. For subsequent pages
756+ * always pass the opaque cursor string from the previous nextCursor — GitHub uses
757+ * cursor-based GraphQL pagination and has no concept of integer page offsets.
758+ * Any integer value other than 1 is treated as page 1.
759+ * @param string $search Prefix filter; empty returns all branches
760+ * @return array{items: array<string>, hasNext: bool, nextCursor: string|null}
752761 */
753- public function listBranches (string $ owner , string $ repositoryName , int $ perPage = 100 , int $ page = 1 ): array
762+ public function listBranches (string $ owner , string $ repositoryName , int $ perPage = 100 , int | string | null $ page = 1 , string $ search = '' ): array
754763 {
755- $ url = "/repos/ $ owner/ $ repositoryName/branches " ;
756764 $ perPage = min (max ($ perPage , 1 ), 100 );
765+ $ cursor = is_string ($ page ) ? $ page : null ;
766+
767+ $ gql = <<<'GRAPHQL'
768+ query ListBranches($owner: String!, $name: String!, $first: Int!, $after: String, $query: String) {
769+ repository(owner: $owner, name: $name) {
770+ refs(refPrefix: "refs/heads/", first: $first, after: $after, orderBy: {field: ALPHABETICAL, direction: ASC}, query: $query) {
771+ edges {
772+ cursor
773+ node {
774+ name
775+ }
776+ }
777+ pageInfo {
778+ hasNextPage
779+ endCursor
780+ }
781+ }
782+ }
783+ }
784+ GRAPHQL;
785+
786+ // We use GraphQL instead of REST for two reasons that the REST API cannot satisfy:
787+ // 1. Server-side search narrowing: REST GET /repos/{owner}/{repo}/branches has no
788+ // search or filter parameter at all; GraphQL refs() accepts a `query` variable.
789+ // 2. Per-edge cursors: REST only supports integer ?page=N offsets; GraphQL edges
790+ // carry individual cursors so we can resume from an exact item across calls.
791+ //
792+ // GraphQL `query` does substring matching, so we additionally enforce prefix
793+ // semantics client-side with str_starts_with. We collect up to $perPage + 1
794+ // matching edges across as many GraphQL pages as needed:
795+ // - If we find the +1 probe item, hasNext=true and nextCursor points to the
796+ // cursor of the last returned item, so the next call resumes exactly where
797+ // we stopped.
798+ // - If GitHub is exhausted before the probe, hasNext=false.
799+ // This ensures items is never empty while hasNext is true.
800+ /** @var array<array{name: string, cursor: string}> $collected */
801+ $ collected = [];
802+ $ currentCursor = $ cursor ;
803+ $ hasNextPage = false ;
804+
805+ do {
806+ $ response = $ this ->call (self ::METHOD_POST , '/graphql ' , ['Authorization ' => "Bearer $ this ->accessToken " ], [
807+ 'query ' => $ gql ,
808+ 'variables ' => [
809+ 'owner ' => $ owner ,
810+ 'name ' => $ repositoryName ,
811+ 'first ' => $ perPage ,
812+ 'after ' => $ currentCursor ,
813+ 'query ' => $ search !== '' ? $ search : null ,
814+ ],
815+ ]);
757816
758- $ response = $ this ->call (self ::METHOD_GET , $ url , ['Authorization ' => "Bearer $ this ->accessToken " ], [
759- 'page ' => $ page ,
760- 'per_page ' => $ perPage ,
761- ]);
817+ $ statusCode = $ response ['headers ' ]['status-code ' ] ?? 0 ;
818+ $ responseBody = $ response ['body ' ] ?? [];
762819
763- $ statusCode = $ response ['headers ' ]['status-code ' ] ?? 0 ;
764- $ responseBody = $ response ['body ' ] ?? [];
820+ if ($ statusCode < 200 || $ statusCode >= 300 || !is_array ($ responseBody ) || array_key_exists ('errors ' , $ responseBody )) {
821+ return ['items ' => [], 'hasNext ' => false , 'nextCursor ' => null ];
822+ }
765823
766- if ($ statusCode < 200 || $ statusCode >= 300 || !is_array ($ responseBody )) {
767- return [];
824+ $ refs = $ responseBody ['data ' ]['repository ' ]['refs ' ] ?? null ;
825+
826+ if (!is_array ($ refs )) {
827+ return ['items ' => [], 'hasNext ' => false , 'nextCursor ' => null ];
828+ }
829+
830+ $ pageInfo = $ refs ['pageInfo ' ] ?? [];
831+ $ hasNextPage = (bool ) ($ pageInfo ['hasNextPage ' ] ?? false );
832+ $ currentCursor = $ pageInfo ['endCursor ' ] ?? null ;
833+
834+ $ probeFound = false ;
835+ foreach ($ refs ['edges ' ] ?? [] as $ edge ) {
836+ $ name = $ edge ['node ' ]['name ' ] ?? '' ;
837+ if ($ search === '' || str_starts_with ($ name , $ search )) {
838+ $ collected [] = ['name ' => $ name , 'cursor ' => $ edge ['cursor ' ] ?? '' ];
839+ if (count ($ collected ) > $ perPage ) {
840+ $ probeFound = true ;
841+ break ;
842+ }
843+ }
844+ }
845+
846+ if ($ probeFound ) {
847+ break ;
848+ }
849+ } while ($ hasNextPage );
850+
851+ if (count ($ collected ) > $ perPage ) {
852+ $ toReturn = array_slice ($ collected , 0 , $ perPage );
853+ return [
854+ 'items ' => array_column ($ toReturn , 'name ' ),
855+ 'hasNext ' => true ,
856+ 'nextCursor ' => $ toReturn [$ perPage - 1 ]['cursor ' ],
857+ ];
768858 }
769859
770- return array_values (array_map (fn ($ branch ) => $ branch ['name ' ] ?? '' , $ responseBody ));
860+ return [
861+ 'items ' => array_column ($ collected , 'name ' ),
862+ 'hasNext ' => false ,
863+ 'nextCursor ' => null ,
864+ ];
771865 }
772866
773867 /**
@@ -831,15 +925,15 @@ public function getLatestCommit(string $owner, string $repositoryName, string $b
831925 $ responseBody = $ response ['body ' ] ?? [];
832926 $ responseBodyCommit = $ responseBody ['commit ' ] ?? [];
833927 $ responseBodyCommitAuthor = $ responseBodyCommit ['author ' ] ?? [];
834- $ responseBodyAuthor = $ responseBody ['author ' ] ?? [];
928+ // GitHub sets author to null for commits from App installations whose email
929+ // does not match any GitHub user — treat it as an empty array to allow fallbacks.
930+ $ responseBodyAuthor = is_array ($ responseBody ['author ' ] ?? null ) ? $ responseBody ['author ' ] : [];
835931
836932 if (
837933 !array_key_exists ('name ' , $ responseBodyCommitAuthor ) ||
838934 !array_key_exists ('message ' , $ responseBodyCommit ) ||
839935 !array_key_exists ('sha ' , $ responseBody ) ||
840- !array_key_exists ('html_url ' , $ responseBody ) ||
841- !array_key_exists ('avatar_url ' , $ responseBodyAuthor ) ||
842- !array_key_exists ('html_url ' , $ responseBodyAuthor )
936+ !array_key_exists ('html_url ' , $ responseBody )
843937 ) {
844938 throw new Exception ("Latest commit response is missing required information. " );
845939 }
@@ -872,185 +966,6 @@ public function updateCommitStatus(string $repositoryName, string $commitHash, s
872966 $ this ->call (self ::METHOD_POST , $ url , ['Authorization ' => "Bearer $ this ->accessToken " ], $ body );
873967 }
874968
875- /**
876- * Creates a check run for a commit.
877- * status can be one of: queued, in_progress, completed
878- * conclusion (required when status=completed) can be one of: action_required, cancelled, failure, neutral, success, skipped, timed_out
879- *
880- * @param array<mixed> $annotations
881- * @param array<mixed> $images
882- * @param array<mixed> $actions
883- * @return array<mixed>
884- */
885- public function createCheckRun (
886- string $ owner ,
887- string $ repositoryName ,
888- string $ headSha ,
889- string $ name ,
890- string $ status = 'queued ' ,
891- string $ conclusion = '' ,
892- string $ title = '' ,
893- string $ summary = '' ,
894- string $ text = '' ,
895- array $ annotations = [],
896- array $ images = [],
897- array $ actions = [],
898- string $ detailsUrl = '' ,
899- string $ externalId = '' ,
900- string $ startedAt = '' ,
901- string $ completedAt = '' ,
902- ): array {
903- $ url = "/repos/ $ owner/ $ repositoryName/check-runs " ;
904-
905- if ($ status === 'completed ' && empty ($ conclusion )) {
906- throw new Exception ("conclusion is required when status is 'completed' " );
907- }
908-
909- // Conclusion requires status=completed; auto-set completed_at if not provided.
910- if (!empty ($ conclusion )) {
911- $ status = 'completed ' ;
912- if (empty ($ completedAt )) {
913- $ completedAt = gmdate ('Y-m-d\TH:i:s\Z ' );
914- }
915- }
916-
917- $ body = array_merge (
918- [
919- 'name ' => $ name ,
920- 'head_sha ' => $ headSha ,
921- 'status ' => $ status ,
922- ],
923- array_filter ([
924- 'conclusion ' => $ conclusion ,
925- 'completed_at ' => $ completedAt ,
926- 'details_url ' => $ detailsUrl ,
927- 'external_id ' => $ externalId ,
928- 'started_at ' => $ startedAt ,
929- ], fn ($ value ) => !empty ($ value ))
930- );
931-
932- // Output requires both title and summary.
933- if (!empty ($ title ) && !empty ($ summary )) {
934- $ output = array_filter (['title ' => $ title , 'summary ' => $ summary , 'text ' => $ text ], fn ($ value ) => !empty ($ value ));
935- if (!empty ($ annotations )) {
936- $ output ['annotations ' ] = $ annotations ;
937- }
938- if (!empty ($ images )) {
939- $ output ['images ' ] = $ images ;
940- }
941- $ body ['output ' ] = $ output ;
942- }
943-
944- if (!empty ($ actions )) {
945- $ body ['actions ' ] = $ actions ;
946- }
947-
948- $ response = $ this ->call (self ::METHOD_POST , $ url , ['Authorization ' => "Bearer $ this ->accessToken " ], $ body );
949-
950- $ responseHeadersStatusCode = $ response ['headers ' ]['status-code ' ] ?? 0 ;
951- if ($ responseHeadersStatusCode >= 400 ) {
952- throw new Exception ("Failed to create check run: HTTP $ responseHeadersStatusCode " );
953- }
954-
955- return $ response ['body ' ] ?? [];
956- }
957-
958- /**
959- * Gets a check run by ID.
960- *
961- * @return array<mixed>
962- */
963- public function getCheckRun (string $ owner , string $ repositoryName , int $ checkRunId ): array
964- {
965- $ url = "/repos/ $ owner/ $ repositoryName/check-runs/ $ checkRunId " ;
966-
967- $ response = $ this ->call (self ::METHOD_GET , $ url , ['Authorization ' => "Bearer $ this ->accessToken " ]);
968-
969- $ responseHeadersStatusCode = $ response ['headers ' ]['status-code ' ] ?? 0 ;
970- if ($ responseHeadersStatusCode >= 400 ) {
971- throw new Exception ("Failed to get check run $ checkRunId: HTTP $ responseHeadersStatusCode " );
972- }
973-
974- return $ response ['body ' ] ?? [];
975- }
976-
977- /**
978- * Updates an existing check run.
979- * status can be one of: queued, in_progress, completed
980- * conclusion (required when status=completed) can be one of: action_required, cancelled, failure, neutral, success, skipped, timed_out
981- *
982- * @param array<mixed> $annotations
983- * @param array<mixed> $images
984- * @return array<mixed>
985- */
986- public function updateCheckRun (
987- string $ owner ,
988- string $ repositoryName ,
989- int $ checkRunId ,
990- string $ name = '' ,
991- string $ status = '' ,
992- string $ conclusion = '' ,
993- string $ title = '' ,
994- string $ summary = '' ,
995- string $ text = '' ,
996- array $ annotations = [],
997- array $ images = [],
998- array $ actions = [],
999- string $ detailsUrl = '' ,
1000- string $ externalId = '' ,
1001- string $ startedAt = '' ,
1002- string $ completedAt = '' ,
1003- ): array {
1004- $ url = "/repos/ $ owner/ $ repositoryName/check-runs/ $ checkRunId " ;
1005-
1006- if ($ status === 'completed ' && empty ($ conclusion )) {
1007- throw new Exception ("conclusion is required when status is 'completed' " );
1008- }
1009-
1010- // Conclusion requires status=completed; auto-set completed_at if not provided.
1011- if (!empty ($ conclusion )) {
1012- $ status = 'completed ' ;
1013- if (empty ($ completedAt )) {
1014- $ completedAt = gmdate ('Y-m-d\TH:i:s\Z ' );
1015- }
1016- }
1017-
1018- $ body = array_filter ([
1019- 'name ' => $ name ,
1020- 'status ' => $ status ,
1021- 'details_url ' => $ detailsUrl ,
1022- 'external_id ' => $ externalId ,
1023- 'started_at ' => $ startedAt ,
1024- 'conclusion ' => $ conclusion ,
1025- 'completed_at ' => $ completedAt ,
1026- ], fn ($ value ) => !empty ($ value ));
1027-
1028- // Output requires both title and summary.
1029- if (!empty ($ title ) && !empty ($ summary )) {
1030- $ output = array_filter (['title ' => $ title , 'summary ' => $ summary , 'text ' => $ text ], fn ($ value ) => !empty ($ value ));
1031- if (!empty ($ annotations )) {
1032- $ output ['annotations ' ] = $ annotations ;
1033- }
1034- if (!empty ($ images )) {
1035- $ output ['images ' ] = $ images ;
1036- }
1037- $ body ['output ' ] = $ output ;
1038- }
1039-
1040- if (!empty ($ actions )) {
1041- $ body ['actions ' ] = $ actions ;
1042- }
1043-
1044- $ response = $ this ->call (self ::METHOD_PATCH , $ url , ['Authorization ' => "Bearer $ this ->accessToken " ], $ body );
1045-
1046- $ responseHeadersStatusCode = $ response ['headers ' ]['status-code ' ] ?? 0 ;
1047- if ($ responseHeadersStatusCode >= 400 ) {
1048- throw new Exception ("Failed to update check run $ checkRunId: HTTP $ responseHeadersStatusCode " );
1049- }
1050-
1051- return $ response ['body ' ] ?? [];
1052- }
1053-
1054969 /**
1055970 * Generates a clone command using app access token
1056971 */
0 commit comments