Skip to content

Commit c9482b4

Browse files
committed
Add server-side branch prefix search and cursor pagination for GitHub
Uses GraphQL refs query with query variable for prefix filtering and cursor-based pagination instead of fetching all branches client-side.
1 parent 59b6483 commit c9482b4

2 files changed

Lines changed: 167 additions & 494 deletions

File tree

src/VCS/Adapter/Git/GitHub.php

Lines changed: 115 additions & 200 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)