From b38bfa5a4dab5f4374919048ac9090b5ba24aba9 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 16 Jul 2026 13:25:56 +0530 Subject: [PATCH 01/16] Fix GitLab::getEvent() field/action parity with Gitea's shape Verified against real GitLab webhook payloads: push/MR events were missing repositoryId (breaking any DB lookup keyed on it), used non-standard key names (name/commitAuthor/commitMessage/commitUrl instead of repositoryName/headCommitAuthorName/headCommitMessage/ headCommitUrl), and passed merge_request action through raw (open/reopen/update/close) instead of normalizing to the GitHub/Gitea convention (opened/reopened/synchronize/closed) consumers already key off of. Also adds repositoryUrl, branchUrl, affectedFiles, branch create/delete detection, and cross-project (fork) detection for MRs, matching Gitea's return shape field-for-field. --- src/VCS/Adapter/Git/GitLab.php | 109 +++++++++++++++++++++++-------- tests/VCS/Adapter/GitLabTest.php | 84 ++++++++++++++++++++---- 2 files changed, 153 insertions(+), 40 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 51b20f49..858a676b 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -974,6 +974,20 @@ public function generateCloneCommand(string $owner, string $repositoryName, stri return implode(' && ', $commands); } + /** + * Maps GitLab's native merge_request action to the GitHub/Gitea-style + * verbs the rest of the library (and its consumers) already key off of. + * GitLab also has a distinct 'merge' action for a completed MR, which + * consumers should treat the same as 'closed' (the MR is done either way). + */ + private const MERGE_REQUEST_ACTION_MAP = [ + 'open' => 'opened', + 'reopen' => 'reopened', + 'update' => 'synchronize', + 'close' => 'closed', + 'merge' => 'closed', + ]; + public function getEvent(string $event, string $payload): array { $payloadArray = json_decode($payload, true); @@ -983,6 +997,7 @@ public function getEvent(string $event, string $payload): array switch ($event) { case 'Push Hook': + $project = $payloadArray['project'] ?? []; $commits = $payloadArray['commits'] ?? []; $checkoutSha = $payloadArray['checkout_sha'] ?? ''; $latestCommit = []; @@ -993,46 +1008,82 @@ public function getEvent(string $event, string $payload): array } } if (empty($latestCommit) && !empty($commits)) { - $latestCommit = $commits[0]; + $latestCommit = $commits[array_key_last($commits)]; + } + + $repositoryId = strval($project['id'] ?? ''); + $repositoryName = $project['name'] ?? ''; + $repositoryUrl = $project['web_url'] ?? ''; + $owner = $project['namespace'] ?? ''; + $branch = str_replace('refs/heads/', '', $payloadArray['ref'] ?? ''); + $branchUrl = !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . '/-/tree/' . $branch : ''; + + $affectedFiles = []; + foreach ($commits as $commit) { + foreach (['added', 'modified', 'removed'] as $changeType) { + foreach (($commit[$changeType] ?? []) as $file) { + $affectedFiles[$file] = true; + } + } } - $ref = $payloadArray['ref'] ?? ''; - // ref format: refs/heads/main - $branch = str_replace('refs/heads/', '', $ref); + + $allZeroSha = str_repeat('0', 40); return [ - 'type' => 'push', - 'name' => $payloadArray['project']['name'] ?? '', - 'owner' => $payloadArray['project']['namespace'] ?? '', + 'branchCreated' => ($payloadArray['before'] ?? '') === $allZeroSha, + 'branchDeleted' => ($payloadArray['after'] ?? '') === $allZeroSha, 'branch' => $branch, - 'commitHash' => $payloadArray['checkout_sha'] ?? '', - 'commitAuthor' => $latestCommit['author']['name'] ?? '', - 'commitMessage' => $latestCommit['message'] ?? '', - 'commitUrl' => $latestCommit['url'] ?? '', - 'commitAuthorUrl' => '', - 'commitAuthorAvatar' => '', + 'branchUrl' => $branchUrl, + 'repositoryId' => $repositoryId, + 'repositoryName' => $repositoryName, + 'repositoryUrl' => $repositoryUrl, + 'installationId' => '', // GitLab personal installs have none + 'commitHash' => $checkoutSha, + 'owner' => $owner, + 'authorUrl' => '', + 'authorAvatarUrl' => $payloadArray['user_avatar'] ?? '', + 'headCommitAuthorName' => $latestCommit['author']['name'] ?? '', + 'headCommitAuthorEmail' => $latestCommit['author']['email'] ?? '', + 'headCommitMessage' => $latestCommit['message'] ?? '', + 'headCommitUrl' => $latestCommit['url'] ?? '', + 'external' => false, + 'pullRequestNumber' => '', + 'action' => '', + 'affectedFiles' => \array_keys($affectedFiles), ]; case 'Merge Request Hook': + $project = $payloadArray['project'] ?? []; $mr = $payloadArray['object_attributes'] ?? []; - $action = $mr['action'] ?? ''; + + $repositoryId = strval($project['id'] ?? ''); + $repositoryName = $project['name'] ?? ''; + $repositoryUrl = $project['web_url'] ?? ''; + $owner = $project['namespace'] ?? ''; + $branch = $mr['source_branch'] ?? ''; + $branchUrl = !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . '/-/tree/' . $branch : ''; + $action = self::MERGE_REQUEST_ACTION_MAP[$mr['action'] ?? ''] ?? ''; + + // Cross-project MRs (source/target in different projects) are + // GitLab's equivalent of a fork-based external contribution. + $external = isset($mr['source_project_id'], $mr['target_project_id']) + && $mr['source_project_id'] !== $mr['target_project_id']; return [ - 'type' => 'pull_request', - 'name' => $payloadArray['project']['name'] ?? '', - 'owner' => $payloadArray['project']['namespace'] ?? '', - 'branch' => $mr['source_branch'] ?? '', - 'action' => $action, - 'pullRequestNumber' => $mr['iid'] ?? 0, - 'pullRequestTitle' => $mr['title'] ?? '', - 'pullRequestUrl' => $mr['url'] ?? '', - 'headBranch' => $mr['source_branch'] ?? '', - 'baseBranch' => $mr['target_branch'] ?? '', + 'branch' => $branch, + 'branchUrl' => $branchUrl, + 'repositoryId' => $repositoryId, + 'repositoryName' => $repositoryName, + 'repositoryUrl' => $repositoryUrl, + 'installationId' => '', 'commitHash' => $mr['last_commit']['id'] ?? '', - 'commitUrl' => $mr['last_commit']['url'] ?? '', - 'commitMessage' => $mr['last_commit']['message'] ?? '', - 'commitAuthor' => $mr['last_commit']['author']['name'] ?? '', - 'commitAuthorUrl' => '', - 'commitAuthorAvatar' => '', + 'owner' => $owner, + 'authorUrl' => '', + 'authorAvatarUrl' => $payloadArray['user']['avatar_url'] ?? '', + 'headCommitUrl' => $mr['last_commit']['url'] ?? '', + 'external' => $external, + 'pullRequestNumber' => $mr['iid'] ?? '', + 'action' => $action, ]; default: diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 42322e11..565b107f 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -719,16 +719,25 @@ public function testGetEventPush(): void $payload = json_encode([ 'object_kind' => 'push', 'ref' => 'refs/heads/main', + 'before' => 'before123', + 'after' => 'abc123', 'checkout_sha' => 'abc123', + 'user_avatar' => 'http://example.com/avatar.png', 'project' => [ + 'id' => 123, 'name' => 'test-repo', 'namespace' => 'test-org', + 'web_url' => 'http://example.com/test-org/test-repo', ], 'commits' => [ [ + 'id' => 'abc123', 'message' => 'Test commit', 'url' => 'http://example.com/commit/abc123', - 'author' => ['name' => 'Test User'], + 'author' => ['name' => 'Test User', 'email' => 'test@example.com'], + 'added' => ['file1.txt'], + 'modified' => [], + 'removed' => [], ], ], ]); @@ -740,12 +749,19 @@ public function testGetEventPush(): void $result = $this->vcsAdapter->getEvent('Push Hook', $payload); $this->assertIsArray($result); - $this->assertSame('push', $result['type']); + $this->assertFalse($result['branchDeleted']); $this->assertSame('main', $result['branch']); + $this->assertSame('http://example.com/test-org/test-repo/-/tree/main', $result['branchUrl']); + $this->assertSame('123', $result['repositoryId']); + $this->assertSame('test-repo', $result['repositoryName']); + $this->assertSame('http://example.com/test-org/test-repo', $result['repositoryUrl']); + $this->assertSame('test-org', $result['owner']); $this->assertSame('abc123', $result['commitHash']); - $this->assertSame('Test commit', $result['commitMessage']); - $this->assertSame('Test User', $result['commitAuthor']); - $this->assertSame('test-repo', $result['name']); + $this->assertSame('Test User', $result['headCommitAuthorName']); + $this->assertSame('test@example.com', $result['headCommitAuthorEmail']); + $this->assertSame('Test commit', $result['headCommitMessage']); + $this->assertSame('http://example.com/commit/abc123', $result['headCommitUrl']); + $this->assertSame(['file1.txt'], $result['affectedFiles']); } public function testGetEventPullRequest(): void @@ -753,8 +769,10 @@ public function testGetEventPullRequest(): void $payload = json_encode([ 'object_kind' => 'merge_request', 'project' => [ + 'id' => 123, 'name' => 'test-repo', 'namespace' => 'test-org', + 'web_url' => 'http://example.com/test-org/test-repo', ], 'object_attributes' => [ 'iid' => 1, @@ -762,6 +780,8 @@ public function testGetEventPullRequest(): void 'action' => 'open', 'source_branch' => 'feature', 'target_branch' => 'main', + 'source_project_id' => 123, + 'target_project_id' => 123, 'url' => 'http://example.com/mr/1', 'last_commit' => [ 'id' => 'abc123', @@ -779,14 +799,56 @@ public function testGetEventPullRequest(): void $result = $this->vcsAdapter->getEvent('Merge Request Hook', $payload); $this->assertIsArray($result); - $this->assertSame('pull_request', $result['type']); $this->assertSame('feature', $result['branch']); - $this->assertSame('open', $result['action']); + $this->assertSame('opened', $result['action']); + $this->assertFalse($result['external']); $this->assertSame(1, $result['pullRequestNumber']); - $this->assertSame('Test MR', $result['pullRequestTitle']); + $this->assertSame('123', $result['repositoryId']); + $this->assertSame('test-repo', $result['repositoryName']); $this->assertSame('abc123', $result['commitHash']); } + public function testGetEventPullRequestActionMapping(): void + { + foreach (['open' => 'opened', 'reopen' => 'reopened', 'update' => 'synchronize', 'close' => 'closed', 'merge' => 'closed'] as $native => $mapped) { + $payload = json_encode([ + 'object_kind' => 'merge_request', + 'project' => ['id' => 1, 'name' => 'r', 'namespace' => 'o', 'web_url' => 'http://example.com/o/r'], + 'object_attributes' => ['iid' => 1, 'action' => $native, 'source_branch' => 'f', 'target_branch' => 'main'], + ]); + + if ($payload === false) { + $this->fail('Failed to encode JSON payload'); + } + + $result = $this->vcsAdapter->getEvent('Merge Request Hook', $payload); + $this->assertSame($mapped, $result['action'], "native action '{$native}' should map to '{$mapped}'"); + } + } + + public function testGetEventPullRequestDetectsExternal(): void + { + $payload = json_encode([ + 'object_kind' => 'merge_request', + 'project' => ['id' => 1, 'name' => 'r', 'namespace' => 'o', 'web_url' => 'http://example.com/o/r'], + 'object_attributes' => [ + 'iid' => 1, + 'action' => 'open', + 'source_branch' => 'f', + 'target_branch' => 'main', + 'source_project_id' => 456, + 'target_project_id' => 123, + ], + ]); + + if ($payload === false) { + $this->fail('Failed to encode JSON payload'); + } + + $result = $this->vcsAdapter->getEvent('Merge Request Hook', $payload); + $this->assertTrue($result['external']); + } + public function testGetEventUnknown(): void { $result = $this->vcsAdapter->getEvent('Unknown Hook', '{}'); @@ -1353,9 +1415,9 @@ public function testGetEventPushMatchesCheckoutSha(): void $this->assertIsArray($result); $this->assertSame('def456', $result['commitHash']); - $this->assertSame('Head Author', $result['commitAuthor']); - $this->assertSame('Head commit', $result['commitMessage']); - $this->assertSame('http://example.com/commit/def456', $result['commitUrl']); + $this->assertSame('Head Author', $result['headCommitAuthorName']); + $this->assertSame('Head commit', $result['headCommitMessage']); + $this->assertSame('http://example.com/commit/def456', $result['headCommitUrl']); } public function testValidateWebhookEventUsesPlainToken(): void From 6327c9e0b56d831108ced41543e1a74f1c9056cf Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 16 Jul 2026 13:39:10 +0530 Subject: [PATCH 02/16] Address review: cover branch create/delete sentinels, clarify external default - Added dedicated tests for branchCreated/branchDeleted sentinel paths. - Documented the external=false fallback as an intentional safe default. - Reverted a pullRequestNumber strval() cast: Gitea/GitHub both return the raw int-or-empty-string from getEvent() too, so casting only GitLab would trade one inconsistency for another. Leaving all three adapters consistent with each other for now; a real fix needs to touch all three, which is a separate, broader change. --- src/VCS/Adapter/Git/GitLab.php | 2 ++ tests/VCS/Adapter/GitLabTest.php | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 858a676b..159812ed 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -1066,6 +1066,8 @@ public function getEvent(string $event, string $payload): array // Cross-project MRs (source/target in different projects) are // GitLab's equivalent of a fork-based external contribution. + // Defaults to false (not external) if either ID is missing -- + // an intentional safe default, not an oversight. $external = isset($mr['source_project_id'], $mr['target_project_id']) && $mr['source_project_id'] !== $mr['target_project_id']; diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 565b107f..7a54c290 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -764,6 +764,50 @@ public function testGetEventPush(): void $this->assertSame(['file1.txt'], $result['affectedFiles']); } + public function testGetEventPushDetectsBranchCreated(): void + { + $allZeroSha = str_repeat('0', 40); + $payload = json_encode([ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'before' => $allZeroSha, + 'after' => 'abc123', + 'checkout_sha' => 'abc123', + 'project' => ['id' => 123, 'name' => 'test-repo', 'namespace' => 'test-org', 'web_url' => 'http://example.com/test-org/test-repo'], + 'commits' => [], + ]); + + if ($payload === false) { + $this->fail('Failed to encode JSON payload'); + } + + $result = $this->vcsAdapter->getEvent('Push Hook', $payload); + $this->assertTrue($result['branchCreated']); + $this->assertFalse($result['branchDeleted']); + } + + public function testGetEventPushDetectsBranchDeleted(): void + { + $allZeroSha = str_repeat('0', 40); + $payload = json_encode([ + 'object_kind' => 'push', + 'ref' => 'refs/heads/main', + 'before' => 'abc123', + 'after' => $allZeroSha, + 'checkout_sha' => '', + 'project' => ['id' => 123, 'name' => 'test-repo', 'namespace' => 'test-org', 'web_url' => 'http://example.com/test-org/test-repo'], + 'commits' => [], + ]); + + if ($payload === false) { + $this->fail('Failed to encode JSON payload'); + } + + $result = $this->vcsAdapter->getEvent('Push Hook', $payload); + $this->assertFalse($result['branchCreated']); + $this->assertTrue($result['branchDeleted']); + } + public function testGetEventPullRequest(): void { $payload = json_encode([ From 184c95ca6eb30066e3a37125c2835af5d3c8c727 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 16 Jul 2026 13:43:40 +0530 Subject: [PATCH 03/16] Trim verbose comments in GitLab::getEvent() --- src/VCS/Adapter/Git/GitLab.php | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 159812ed..d1ac35fc 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -974,12 +974,7 @@ public function generateCloneCommand(string $owner, string $repositoryName, stri return implode(' && ', $commands); } - /** - * Maps GitLab's native merge_request action to the GitHub/Gitea-style - * verbs the rest of the library (and its consumers) already key off of. - * GitLab also has a distinct 'merge' action for a completed MR, which - * consumers should treat the same as 'closed' (the MR is done either way). - */ + // Maps GitLab's native action to the GitHub/Gitea verbs consumers key off of; 'merge' counts as 'closed' too. private const MERGE_REQUEST_ACTION_MAP = [ 'open' => 'opened', 'reopen' => 'reopened', @@ -1064,10 +1059,7 @@ public function getEvent(string $event, string $payload): array $branchUrl = !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . '/-/tree/' . $branch : ''; $action = self::MERGE_REQUEST_ACTION_MAP[$mr['action'] ?? ''] ?? ''; - // Cross-project MRs (source/target in different projects) are - // GitLab's equivalent of a fork-based external contribution. - // Defaults to false (not external) if either ID is missing -- - // an intentional safe default, not an oversight. + // Cross-project MR = fork-based external contribution; defaults to false (intentional) if IDs are missing. $external = isset($mr['source_project_id'], $mr['target_project_id']) && $mr['source_project_id'] !== $mr['target_project_id']; From 199e3bd53c527d1542c116405b5c70376100d071 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 16 Jul 2026 17:12:14 +0530 Subject: [PATCH 04/16] Fix searchRepositories() returning no private repos for personal namespaces GET /users/:id/projects only ever returns *public* projects, even for the authenticated user's own account -- confirmed live against a real GitLab account with private repos, which came back empty. Falls back to GET /projects?membership=true instead (every project, public and private, the token's user is a member of), filtered client-side to the requested owner's namespace since that endpoint isn't scoped to one owner. The group-namespace path is untouched, since it was already correctly scoped by GitLab itself. Note: the existing testSearchRepositories/testSearchRepositoriesWithSearch tests only cover a public repo, which is exactly why this didn't get caught -- worth adding a private-repo case as a follow-up. --- src/VCS/Adapter/Git/GitLab.php | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index d1ac35fc..c3d42755 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -255,9 +255,18 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; - // Fall back to user namespace if group not found + // Fall back to the user's personal namespace if there's no group by + // that name. GET /users/:id/projects only ever returns *public* + // projects, even for the authenticated user's own account -- there's + // no GitLab endpoint that lists a specific personal namespace's + // private projects directly. GET /projects?membership=true does + // return every project (public and private) the token's user is a + // member of, but across all namespaces, so it's filtered below to + // just the requested owner. + $filterByNamespace = false; if ($statusCode === 404) { - $url = "/users/{$ownerPath}/projects?page={$page}&per_page={$per_page}"; + $filterByNamespace = true; + $url = "/projects?membership=true&page={$page}&per_page={$per_page}"; if (!empty($search)) { $url .= "&search=" . urlencode($search); } @@ -277,6 +286,10 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri $repositories = []; foreach ($responseBody as $repo) { + if ($filterByNamespace && ($repo['namespace']['path'] ?? '') !== $ownerPath) { + continue; + } + $repositories[] = [ 'id' => $repo['id'] ?? 0, 'name' => $repo['name'] ?? '', From 3291a27711637e076f2f1d66348aa72312fde2e9 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 16 Jul 2026 17:44:28 +0530 Subject: [PATCH 05/16] Fix GitLab adapter authenticating with the wrong header/param for OAuth2 tokens Every API call used the PRIVATE-TOKEN header (and ?private_token= for the presigned archive URL), which GitLab only honors for Personal Access Tokens. Our entire integration is OAuth2-based (authorization code flow), which GitLab requires via Authorization: Bearer (or ?access_token= for URL-embedded tokens) -- confirmed live: a freshly-issued OAuth2 access token was rejected with 401 'Bad credentials' on every single request, immediately after being minted, which ruled out expiry/staleness and pointed at the auth mechanism itself. --- src/VCS/Adapter/Git/GitLab.php | 68 +++++++++++++++++----------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index c3d42755..16c099d9 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -95,7 +95,7 @@ public function createOrganization(string $orgName): string { $url = "/groups"; - $response = $this->call(self::METHOD_POST, $url, ['PRIVATE-TOKEN' => $this->accessToken], [ + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [ 'name' => $orgName, 'path' => $orgName, 'visibility' => 'public', @@ -140,7 +140,7 @@ public function createRepository(string $owner, string $repositoryName, bool $pr $url = "/projects"; - $response = $this->call(self::METHOD_POST, $url, ['PRIVATE-TOKEN' => $this->accessToken], [ + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [ 'name' => $repositoryName, 'path' => $repositoryName, 'namespace_id' => $namespaceId, @@ -164,7 +164,7 @@ public function deleteRepository(string $owner, string $repositoryName): bool $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $url = "/projects/{$projectPath}"; - $response = $this->call(self::METHOD_DELETE, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -181,7 +181,7 @@ public function getRepository(string $owner, string $repositoryName): array $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $url = "/projects/{$projectPath}"; - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -223,7 +223,7 @@ public function getRepositoryPresignedUrl(string $owner, string $repositoryName, $ownerPath = $this->getOwnerPath($owner); $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); - $url = "{$this->endpoint}/projects/{$projectPath}/repository/archive.{$extension}?private_token=" . urlencode($this->accessToken); + $url = "{$this->endpoint}/projects/{$projectPath}/repository/archive.{$extension}?access_token=" . urlencode($this->accessToken); if (!empty($ref)) { $url .= "&sha=" . urlencode($ref); } @@ -251,7 +251,7 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri $url .= "&search=" . urlencode($search); } - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -270,7 +270,7 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri if (!empty($search)) { $url .= "&search=" . urlencode($search); } - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; } @@ -306,7 +306,7 @@ public function getRepositoryName(string $repositoryId): string { $url = "/projects/{$repositoryId}"; - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -329,7 +329,7 @@ public function getRepositoryTree(string $owner, string $repositoryName, string $allItems = []; do { $pagedUrl = $url . "&recursive=true&per_page=100&page={$page}"; - $response = $this->call(self::METHOD_GET, $pagedUrl, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $pagedUrl, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; if ($responseHeadersStatusCode >= 400) { @@ -345,7 +345,7 @@ public function getRepositoryTree(string $owner, string $repositoryName, string return array_column($allItems, 'path'); } - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -368,7 +368,7 @@ public function getRepositoryContent(string $owner, string $repositoryName, stri $encodedPath = urlencode($path); $url = "/projects/{$projectPath}/repository/files/{$encodedPath}?ref=" . urlencode(empty($ref) ? 'HEAD' : $ref); - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -406,7 +406,7 @@ public function listRepositoryContents(string $owner, string $repositoryName, st $url .= (empty($ref) ? '?' : '&') . 'path=' . urlencode($path); } - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -438,7 +438,7 @@ public function listRepositoryLanguages(string $owner, string $repositoryName): $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $url = "/projects/{$projectPath}/languages"; - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -470,7 +470,7 @@ public function createFile(string $owner, string $repositoryName, string $filepa 'author_email' => 'utopia@example.com', ]; - $response = $this->call(self::METHOD_POST, $url, ['PRIVATE-TOKEN' => $this->accessToken], $payload); + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], $payload); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -487,7 +487,7 @@ public function createBranch(string $owner, string $repositoryName, string $newB $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $url = "/projects/{$projectPath}/repository/branches"; - $response = $this->call(self::METHOD_POST, $url, ['PRIVATE-TOKEN' => $this->accessToken], [ + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [ 'branch' => $newBranchName, 'ref' => $oldBranchName, ]); @@ -514,7 +514,7 @@ public function createPullRequest(string $owner, string $repositoryName, string 'description' => $body, ]; - $response = $this->call(self::METHOD_POST, $url, ['PRIVATE-TOKEN' => $this->accessToken], $payload); + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], $payload); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -539,7 +539,7 @@ public function createWebhook(string $owner, string $repositoryName, string $url 'merge_requests_events' => in_array('pull_request', $events), ]; - $response = $this->call(self::METHOD_POST, $apiUrl, ['PRIVATE-TOKEN' => $this->accessToken], $payload); + $response = $this->call(self::METHOD_POST, $apiUrl, ['Authorization' => 'Bearer ' . $this->accessToken], $payload); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -558,7 +558,7 @@ public function createComment(string $owner, string $repositoryName, int $pullRe $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $url = "/projects/{$projectPath}/merge_requests/{$pullRequestNumber}/notes"; - $response = $this->call(self::METHOD_POST, $url, ['PRIVATE-TOKEN' => $this->accessToken], ['body' => $comment]); + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], ['body' => $comment]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -586,7 +586,7 @@ public function getComment(string $owner, string $repositoryName, string $commen [$mrIid, $noteId] = $parts; $url = "/projects/{$projectPath}/merge_requests/{$mrIid}/notes/{$noteId}"; - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); return $response['body']['body'] ?? ''; } @@ -603,7 +603,7 @@ public function updateComment(string $owner, string $repositoryName, string $com [$mrIid, $noteId] = $parts; $url = "/projects/{$projectPath}/merge_requests/{$mrIid}/notes/{$noteId}"; - $response = $this->call(self::METHOD_PUT, $url, ['PRIVATE-TOKEN' => $this->accessToken], ['body' => $comment]); + $response = $this->call(self::METHOD_PUT, $url, ['Authorization' => 'Bearer ' . $this->accessToken], ['body' => $comment]); $responseHeaders = $response['headers'] ?? []; if (($responseHeaders['status-code'] ?? 0) !== 200) { @@ -617,7 +617,7 @@ public function getUser(string $username): array { $url = "/users?username=" . rawurlencode($username); - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -639,7 +639,7 @@ public function getOwnerName(string $installationId, ?int $repositoryId = null): { if ($repositoryId !== null) { $url = "/projects/{$repositoryId}"; - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; if ($statusCode >= 400) { @@ -651,7 +651,7 @@ public function getOwnerName(string $installationId, ?int $repositoryId = null): } $url = "/user"; - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; if ($statusCode >= 400) { @@ -667,7 +667,7 @@ public function getPullRequest(string $owner, string $repositoryName, int $pullR $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $url = "/projects/{$projectPath}/merge_requests/{$pullRequestNumber}"; - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -703,7 +703,7 @@ public function getPullRequestFiles(string $owner, string $repositoryName, int $ $mrResponse = $this->call( self::METHOD_GET, "/projects/{$projectPath}/merge_requests/{$pullRequestNumber}", - ['PRIVATE-TOKEN' => $this->accessToken] + ['Authorization' => 'Bearer ' . $this->accessToken] ); $mrBody = $mrResponse['body'] ?? []; if (($mrBody['patch_id_sha'] ?? null) !== null) { @@ -719,7 +719,7 @@ public function getPullRequestFiles(string $owner, string $repositoryName, int $ while (true) { $url = "/projects/{$projectPath}/merge_requests/{$pullRequestNumber}/diffs?page={$page}&per_page={$perPage}"; - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -753,7 +753,7 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $url = "/projects/{$projectPath}/merge_requests?state=opened&source_branch=" . urlencode($branch); - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -791,7 +791,7 @@ public function listBranches(string $owner, string $repositoryName): array $page = 1; do { $pagedUrl = "/projects/{$projectPath}/repository/branches?per_page=100&page={$page}"; - $response = $this->call(self::METHOD_GET, $pagedUrl, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $pagedUrl, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; if ($responseHeadersStatusCode >= 400) { @@ -819,7 +819,7 @@ public function listTags(string $owner, string $repositoryName, string $search = $page = 1; do { $pagedUrl = "/projects/{$projectPath}/repository/tags?per_page=100&page={$page}"; - $response = $this->call(self::METHOD_GET, $pagedUrl, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $pagedUrl, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; if ($responseHeadersStatusCode >= 400) { @@ -844,7 +844,7 @@ public function getCommit(string $owner, string $repositoryName, string $commitH $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $url = "/projects/{$projectPath}/repository/commits/" . urlencode($commitHash); - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -870,7 +870,7 @@ public function getLatestCommit(string $owner, string $repositoryName, string $b $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $url = "/projects/{$projectPath}/repository/commits?ref_name=" . urlencode($branch) . "&per_page=1"; - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -928,7 +928,7 @@ public function updateCommitStatus(string $repositoryName, string $commitHash, s $payload['name'] = $context; } - $response = $this->call(self::METHOD_POST, $url, ['PRIVATE-TOKEN' => $this->accessToken], $payload); + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], $payload); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -1122,7 +1122,7 @@ public function createTag(string $owner, string $repositoryName, string $tagName $payload['message'] = $message; } - $response = $this->call(self::METHOD_POST, $url, ['PRIVATE-TOKEN' => $this->accessToken], $payload); + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], $payload); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; @@ -1139,7 +1139,7 @@ public function getCommitStatuses(string $owner, string $repositoryName, string $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); $url = "/projects/{$projectPath}/repository/commits/" . urlencode($commitHash) . "/statuses"; - $response = $this->call(self::METHOD_GET, $url, ['PRIVATE-TOKEN' => $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); $responseHeaders = $response['headers'] ?? []; $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; From a8b8c8efa42b398f187d840fb179b7038c4a3db3 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 16 Jul 2026 17:51:20 +0530 Subject: [PATCH 06/16] Fix searchRepositories() return shape to match GitHub/Gitea's items/total contract GitLab's version returned a bare indexed array; GitHub and Gitea both return ['items' => ..., 'total' => ...] (confirmed by reading both). Consumers destructure that shape directly (e.g. Appwrite's Installations/Repositories/XList.php), so this crashed with a TypeError the moment a real request reached it. Total count comes from GitLab's X-Total response header for the successfully-scoped group/personal-namespace fetch; falls back to the filtered count when client-side namespace filtering is in effect, since X-Total in that case reflects every namespace the token can see, not just the requested owner. --- src/VCS/Adapter/Git/GitLab.php | 17 ++++++++++++++--- tests/VCS/Adapter/GitLabTest.php | 13 ++++++++----- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 16c099d9..6aab9d96 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -276,12 +276,12 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri } if ($statusCode >= 400) { - return []; + return ['items' => [], 'total' => 0]; } $responseBody = $response['body'] ?? []; if (!is_array($responseBody)) { - return []; + return ['items' => [], 'total' => 0]; } $repositories = []; @@ -299,7 +299,18 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri ]; } - return $repositories; + // GitLab returns the total count via the X-Total header, not the + // body. When filtering client-side (personal-namespace fallback), + // that header reflects every namespace the token can see, not just + // the requested owner, so the filtered count is used instead. + $total = $filterByNamespace + ? \count($repositories) + : (int) ($responseHeaders['x-total'] ?? \count($repositories)); + + return [ + 'items' => $repositories, + 'total' => $total, + ]; } public function getRepositoryName(string $repositoryId): string diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 7a54c290..b4e110f5 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -217,12 +217,14 @@ public function testSearchRepositories(): void $result = $this->vcsAdapter->searchRepositories(static::$owner, 1, 10); $this->assertIsArray($result); - $this->assertNotEmpty($result); + $this->assertArrayHasKey('items', $result); + $this->assertArrayHasKey('total', $result); + $this->assertNotEmpty($result['items']); - $names = array_column($result, 'name'); + $names = array_column($result['items'], 'name'); $this->assertContains($repositoryName, $names); - foreach ($result as $repo) { + foreach ($result['items'] as $repo) { $this->assertArrayHasKey('id', $repo); $this->assertArrayHasKey('name', $repo); $this->assertArrayHasKey('private', $repo); @@ -242,9 +244,10 @@ public function testSearchRepositoriesWithSearch(): void $result = $this->vcsAdapter->searchRepositories(static::$owner, 1, 10, $uniqueId); $this->assertIsArray($result); - $this->assertNotEmpty($result); + $this->assertArrayHasKey('items', $result); + $this->assertNotEmpty($result['items']); - $names = array_column($result, 'name'); + $names = array_column($result['items'], 'name'); $this->assertContains($repositoryName, $names); } finally { $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); From 77853444e49e5b711f3770d215a14184c4553750 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 17 Jul 2026 15:13:58 +0530 Subject: [PATCH 07/16] Fix stale test assertion for presigned URL query param testGetRepositoryPresignedUrl still asserted the old private_token query param; the adapter now emits access_token to match OAuth2 auth. --- tests/VCS/Adapter/GitLabTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index b4e110f5..1aec3577 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -70,11 +70,11 @@ public function testGetRepositoryPresignedUrl(): void $owner = static::$owner; $url = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch); - $this->assertStringContainsString('/repository/archive.tar.gz?private_token=', $url); + $this->assertStringContainsString('/repository/archive.tar.gz?access_token=', $url); $this->assertStringContainsString('&sha=' . static::$defaultBranch, $url); $zip = $adapter->getRepositoryPresignedUrl($owner, 'some-repo', static::$defaultBranch, 'zipball'); - $this->assertStringContainsString('/repository/archive.zip?private_token=', $zip); + $this->assertStringContainsString('/repository/archive.zip?access_token=', $zip); // Without a ref the sha param is omitted so the server uses the default branch $noRef = $adapter->getRepositoryPresignedUrl($owner, 'some-repo'); From 395ad2c2cd06ee52b86eff6f825fec55c7399e99 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 17 Jul 2026 15:17:48 +0530 Subject: [PATCH 08/16] Simplify affected-files collection in getEvent() to two loops instead of three --- src/VCS/Adapter/Git/GitLab.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 6aab9d96..649d4ecd 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -1039,10 +1039,9 @@ public function getEvent(string $event, string $payload): array $affectedFiles = []; foreach ($commits as $commit) { - foreach (['added', 'modified', 'removed'] as $changeType) { - foreach (($commit[$changeType] ?? []) as $file) { - $affectedFiles[$file] = true; - } + $changedFiles = \array_merge($commit['added'] ?? [], $commit['modified'] ?? [], $commit['removed'] ?? []); + foreach ($changedFiles as $file) { + $affectedFiles[$file] = true; } } From f592e200659fb8f8c8d967e93b3e7c3476e9a85a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 17 Jul 2026 15:19:46 +0530 Subject: [PATCH 09/16] Trim verbose comment on personal-namespace search fallback --- src/VCS/Adapter/Git/GitLab.php | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 649d4ecd..b7ba2afd 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -255,14 +255,9 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; - // Fall back to the user's personal namespace if there's no group by - // that name. GET /users/:id/projects only ever returns *public* - // projects, even for the authenticated user's own account -- there's - // no GitLab endpoint that lists a specific personal namespace's - // private projects directly. GET /projects?membership=true does - // return every project (public and private) the token's user is a - // member of, but across all namespaces, so it's filtered below to - // just the requested owner. + // No group by that name -- fall back to /projects?membership=true + // (GitLab has no endpoint listing a personal namespace's private + // projects directly) and filter to the requested owner below. $filterByNamespace = false; if ($statusCode === 404) { $filterByNamespace = true; From b7ce7176a4564002373ffdfcb34238ee59f71632 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 17 Jul 2026 15:22:10 +0530 Subject: [PATCH 10/16] Fix personal-namespace search fallback dropping repos on later membership pages Previously fetched one page of /projects?membership=true and filtered it client-side, so a user's own repos could land on a later page than requested and searchRepositories() would return an empty result even though matching repos existed. Now collects all owner-matching projects across membership pages first, then paginates the filtered set. --- src/VCS/Adapter/Git/GitLab.php | 95 +++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index b7ba2afd..ddd4ea76 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -257,17 +257,19 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri // No group by that name -- fall back to /projects?membership=true // (GitLab has no endpoint listing a personal namespace's private - // projects directly) and filter to the requested owner below. - $filterByNamespace = false; + // projects directly). That endpoint can't be filtered to one + // namespace server-side, so the requested owner's matches could + // land on any membership page -- collect all owner-matching + // projects first, then paginate the filtered set ourselves. if ($statusCode === 404) { - $filterByNamespace = true; - $url = "/projects?membership=true&page={$page}&per_page={$per_page}"; - if (!empty($search)) { - $url .= "&search=" . urlencode($search); - } - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); - $responseHeaders = $response['headers'] ?? []; - $statusCode = $responseHeaders['status-code'] ?? 0; + $ownerRepositories = $this->searchMembershipProjectsByNamespace($ownerPath, $search); + $total = \count($ownerRepositories); + $slice = \array_slice($ownerRepositories, ($page - 1) * $per_page, $per_page); + + return [ + 'items' => \array_map(fn ($repo) => $this->mapSearchedRepository($repo), $slice), + 'total' => $total, + ]; } if ($statusCode >= 400) { @@ -279,32 +281,65 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri return ['items' => [], 'total' => 0]; } - $repositories = []; - foreach ($responseBody as $repo) { - if ($filterByNamespace && ($repo['namespace']['path'] ?? '') !== $ownerPath) { - continue; + $repositories = \array_map(fn ($repo) => $this->mapSearchedRepository($repo), $responseBody); + + // GitLab returns the total count via the X-Total header, not the body. + $total = (int) ($responseHeaders['x-total'] ?? \count($repositories)); + + return [ + 'items' => $repositories, + 'total' => $total, + ]; + } + + /** + * @return array> + */ + protected function searchMembershipProjectsByNamespace(string $ownerPath, string $search): array + { + $matches = []; + $maxPages = 20; // safety cap: 20 * 100 = 2000 membership projects scanned + + for ($page = 1; $page <= $maxPages; $page++) { + $url = "/projects?membership=true&page={$page}&per_page=100"; + if (!empty($search)) { + $url .= "&search=" . urlencode($search); } - $repositories[] = [ - 'id' => $repo['id'] ?? 0, - 'name' => $repo['name'] ?? '', - 'description' => $repo['description'] ?? '', - 'private' => ($repo['visibility'] ?? '') === 'private', - 'pushed_at' => $repo['last_activity_at'] ?? '', - ]; + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $statusCode = $response['headers']['status-code'] ?? 0; + $responseBody = $response['body'] ?? []; + + if ($statusCode >= 400 || !is_array($responseBody) || empty($responseBody)) { + break; + } + + foreach ($responseBody as $repo) { + if (($repo['namespace']['path'] ?? '') === $ownerPath) { + $matches[] = $repo; + } + } + + if (\count($responseBody) < 100) { + break; + } } - // GitLab returns the total count via the X-Total header, not the - // body. When filtering client-side (personal-namespace fallback), - // that header reflects every namespace the token can see, not just - // the requested owner, so the filtered count is used instead. - $total = $filterByNamespace - ? \count($repositories) - : (int) ($responseHeaders['x-total'] ?? \count($repositories)); + return $matches; + } + /** + * @param array $repo + * @return array + */ + protected function mapSearchedRepository(array $repo): array + { return [ - 'items' => $repositories, - 'total' => $total, + 'id' => $repo['id'] ?? 0, + 'name' => $repo['name'] ?? '', + 'description' => $repo['description'] ?? '', + 'private' => ($repo['visibility'] ?? '') === 'private', + 'pushed_at' => $repo['last_activity_at'] ?? '', ]; } From a44e57ed729dda7fbdabf0d80bc2a16aee84e33a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 17 Jul 2026 15:27:30 +0530 Subject: [PATCH 11/16] Revert "Fix personal-namespace search fallback dropping repos on later membership pages" This reverts commit b7ce7176a4564002373ffdfcb34238ee59f71632. --- src/VCS/Adapter/Git/GitLab.php | 95 +++++++++++----------------------- 1 file changed, 30 insertions(+), 65 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index ddd4ea76..b7ba2afd 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -257,19 +257,17 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri // No group by that name -- fall back to /projects?membership=true // (GitLab has no endpoint listing a personal namespace's private - // projects directly). That endpoint can't be filtered to one - // namespace server-side, so the requested owner's matches could - // land on any membership page -- collect all owner-matching - // projects first, then paginate the filtered set ourselves. + // projects directly) and filter to the requested owner below. + $filterByNamespace = false; if ($statusCode === 404) { - $ownerRepositories = $this->searchMembershipProjectsByNamespace($ownerPath, $search); - $total = \count($ownerRepositories); - $slice = \array_slice($ownerRepositories, ($page - 1) * $per_page, $per_page); - - return [ - 'items' => \array_map(fn ($repo) => $this->mapSearchedRepository($repo), $slice), - 'total' => $total, - ]; + $filterByNamespace = true; + $url = "/projects?membership=true&page={$page}&per_page={$per_page}"; + if (!empty($search)) { + $url .= "&search=" . urlencode($search); + } + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; } if ($statusCode >= 400) { @@ -281,65 +279,32 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri return ['items' => [], 'total' => 0]; } - $repositories = \array_map(fn ($repo) => $this->mapSearchedRepository($repo), $responseBody); - - // GitLab returns the total count via the X-Total header, not the body. - $total = (int) ($responseHeaders['x-total'] ?? \count($repositories)); - - return [ - 'items' => $repositories, - 'total' => $total, - ]; - } - - /** - * @return array> - */ - protected function searchMembershipProjectsByNamespace(string $ownerPath, string $search): array - { - $matches = []; - $maxPages = 20; // safety cap: 20 * 100 = 2000 membership projects scanned - - for ($page = 1; $page <= $maxPages; $page++) { - $url = "/projects?membership=true&page={$page}&per_page=100"; - if (!empty($search)) { - $url .= "&search=" . urlencode($search); - } - - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); - $statusCode = $response['headers']['status-code'] ?? 0; - $responseBody = $response['body'] ?? []; - - if ($statusCode >= 400 || !is_array($responseBody) || empty($responseBody)) { - break; - } - - foreach ($responseBody as $repo) { - if (($repo['namespace']['path'] ?? '') === $ownerPath) { - $matches[] = $repo; - } + $repositories = []; + foreach ($responseBody as $repo) { + if ($filterByNamespace && ($repo['namespace']['path'] ?? '') !== $ownerPath) { + continue; } - if (\count($responseBody) < 100) { - break; - } + $repositories[] = [ + 'id' => $repo['id'] ?? 0, + 'name' => $repo['name'] ?? '', + 'description' => $repo['description'] ?? '', + 'private' => ($repo['visibility'] ?? '') === 'private', + 'pushed_at' => $repo['last_activity_at'] ?? '', + ]; } - return $matches; - } + // GitLab returns the total count via the X-Total header, not the + // body. When filtering client-side (personal-namespace fallback), + // that header reflects every namespace the token can see, not just + // the requested owner, so the filtered count is used instead. + $total = $filterByNamespace + ? \count($repositories) + : (int) ($responseHeaders['x-total'] ?? \count($repositories)); - /** - * @param array $repo - * @return array - */ - protected function mapSearchedRepository(array $repo): array - { return [ - 'id' => $repo['id'] ?? 0, - 'name' => $repo['name'] ?? '', - 'description' => $repo['description'] ?? '', - 'private' => ($repo['visibility'] ?? '') === 'private', - 'pushed_at' => $repo['last_activity_at'] ?? '', + 'items' => $repositories, + 'total' => $total, ]; } From a57ce69c18f16c394dd2c0826024ee64542e6b7d Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 17 Jul 2026 15:30:08 +0530 Subject: [PATCH 12/16] Reapply "Fix personal-namespace search fallback dropping repos on later membership pages" This reverts commit a44e57ed729dda7fbdabf0d80bc2a16aee84e33a. --- src/VCS/Adapter/Git/GitLab.php | 95 +++++++++++++++++++++++----------- 1 file changed, 65 insertions(+), 30 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index b7ba2afd..ddd4ea76 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -257,17 +257,19 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri // No group by that name -- fall back to /projects?membership=true // (GitLab has no endpoint listing a personal namespace's private - // projects directly) and filter to the requested owner below. - $filterByNamespace = false; + // projects directly). That endpoint can't be filtered to one + // namespace server-side, so the requested owner's matches could + // land on any membership page -- collect all owner-matching + // projects first, then paginate the filtered set ourselves. if ($statusCode === 404) { - $filterByNamespace = true; - $url = "/projects?membership=true&page={$page}&per_page={$per_page}"; - if (!empty($search)) { - $url .= "&search=" . urlencode($search); - } - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); - $responseHeaders = $response['headers'] ?? []; - $statusCode = $responseHeaders['status-code'] ?? 0; + $ownerRepositories = $this->searchMembershipProjectsByNamespace($ownerPath, $search); + $total = \count($ownerRepositories); + $slice = \array_slice($ownerRepositories, ($page - 1) * $per_page, $per_page); + + return [ + 'items' => \array_map(fn ($repo) => $this->mapSearchedRepository($repo), $slice), + 'total' => $total, + ]; } if ($statusCode >= 400) { @@ -279,32 +281,65 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri return ['items' => [], 'total' => 0]; } - $repositories = []; - foreach ($responseBody as $repo) { - if ($filterByNamespace && ($repo['namespace']['path'] ?? '') !== $ownerPath) { - continue; + $repositories = \array_map(fn ($repo) => $this->mapSearchedRepository($repo), $responseBody); + + // GitLab returns the total count via the X-Total header, not the body. + $total = (int) ($responseHeaders['x-total'] ?? \count($repositories)); + + return [ + 'items' => $repositories, + 'total' => $total, + ]; + } + + /** + * @return array> + */ + protected function searchMembershipProjectsByNamespace(string $ownerPath, string $search): array + { + $matches = []; + $maxPages = 20; // safety cap: 20 * 100 = 2000 membership projects scanned + + for ($page = 1; $page <= $maxPages; $page++) { + $url = "/projects?membership=true&page={$page}&per_page=100"; + if (!empty($search)) { + $url .= "&search=" . urlencode($search); } - $repositories[] = [ - 'id' => $repo['id'] ?? 0, - 'name' => $repo['name'] ?? '', - 'description' => $repo['description'] ?? '', - 'private' => ($repo['visibility'] ?? '') === 'private', - 'pushed_at' => $repo['last_activity_at'] ?? '', - ]; + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $statusCode = $response['headers']['status-code'] ?? 0; + $responseBody = $response['body'] ?? []; + + if ($statusCode >= 400 || !is_array($responseBody) || empty($responseBody)) { + break; + } + + foreach ($responseBody as $repo) { + if (($repo['namespace']['path'] ?? '') === $ownerPath) { + $matches[] = $repo; + } + } + + if (\count($responseBody) < 100) { + break; + } } - // GitLab returns the total count via the X-Total header, not the - // body. When filtering client-side (personal-namespace fallback), - // that header reflects every namespace the token can see, not just - // the requested owner, so the filtered count is used instead. - $total = $filterByNamespace - ? \count($repositories) - : (int) ($responseHeaders['x-total'] ?? \count($repositories)); + return $matches; + } + /** + * @param array $repo + * @return array + */ + protected function mapSearchedRepository(array $repo): array + { return [ - 'items' => $repositories, - 'total' => $total, + 'id' => $repo['id'] ?? 0, + 'name' => $repo['name'] ?? '', + 'description' => $repo['description'] ?? '', + 'private' => ($repo['visibility'] ?? '') === 'private', + 'pushed_at' => $repo['last_activity_at'] ?? '', ]; } From dbbc4508b67761a7d0481ce576e90954ece87b14 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 17 Jul 2026 15:33:43 +0530 Subject: [PATCH 13/16] Shorten personal-namespace fallback comment --- src/VCS/Adapter/Git/GitLab.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index ddd4ea76..90855b33 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -255,12 +255,8 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; - // No group by that name -- fall back to /projects?membership=true - // (GitLab has no endpoint listing a personal namespace's private - // projects directly). That endpoint can't be filtered to one - // namespace server-side, so the requested owner's matches could - // land on any membership page -- collect all owner-matching - // projects first, then paginate the filtered set ourselves. + // No group by that name -- fall back to scanning membership + // projects, since GitLab can't filter that list by namespace. if ($statusCode === 404) { $ownerRepositories = $this->searchMembershipProjectsByNamespace($ownerPath, $search); $total = \count($ownerRepositories); From a75f3f750d5317c97bb9a6a7402dba20612297ed Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 17 Jul 2026 15:45:14 +0530 Subject: [PATCH 14/16] Revert "Shorten personal-namespace fallback comment" This reverts commit dbbc4508b67761a7d0481ce576e90954ece87b14. --- src/VCS/Adapter/Git/GitLab.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 90855b33..ddd4ea76 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -255,8 +255,12 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; - // No group by that name -- fall back to scanning membership - // projects, since GitLab can't filter that list by namespace. + // No group by that name -- fall back to /projects?membership=true + // (GitLab has no endpoint listing a personal namespace's private + // projects directly). That endpoint can't be filtered to one + // namespace server-side, so the requested owner's matches could + // land on any membership page -- collect all owner-matching + // projects first, then paginate the filtered set ourselves. if ($statusCode === 404) { $ownerRepositories = $this->searchMembershipProjectsByNamespace($ownerPath, $search); $total = \count($ownerRepositories); From 2a3efd50cf1ad27609208b788a7286af1e849aa3 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 17 Jul 2026 15:45:14 +0530 Subject: [PATCH 15/16] Revert "Reapply "Fix personal-namespace search fallback dropping repos on later membership pages"" This reverts commit a57ce69c18f16c394dd2c0826024ee64542e6b7d. --- src/VCS/Adapter/Git/GitLab.php | 95 +++++++++++----------------------- 1 file changed, 30 insertions(+), 65 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index ddd4ea76..b7ba2afd 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -257,19 +257,17 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri // No group by that name -- fall back to /projects?membership=true // (GitLab has no endpoint listing a personal namespace's private - // projects directly). That endpoint can't be filtered to one - // namespace server-side, so the requested owner's matches could - // land on any membership page -- collect all owner-matching - // projects first, then paginate the filtered set ourselves. + // projects directly) and filter to the requested owner below. + $filterByNamespace = false; if ($statusCode === 404) { - $ownerRepositories = $this->searchMembershipProjectsByNamespace($ownerPath, $search); - $total = \count($ownerRepositories); - $slice = \array_slice($ownerRepositories, ($page - 1) * $per_page, $per_page); - - return [ - 'items' => \array_map(fn ($repo) => $this->mapSearchedRepository($repo), $slice), - 'total' => $total, - ]; + $filterByNamespace = true; + $url = "/projects?membership=true&page={$page}&per_page={$per_page}"; + if (!empty($search)) { + $url .= "&search=" . urlencode($search); + } + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; } if ($statusCode >= 400) { @@ -281,65 +279,32 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri return ['items' => [], 'total' => 0]; } - $repositories = \array_map(fn ($repo) => $this->mapSearchedRepository($repo), $responseBody); - - // GitLab returns the total count via the X-Total header, not the body. - $total = (int) ($responseHeaders['x-total'] ?? \count($repositories)); - - return [ - 'items' => $repositories, - 'total' => $total, - ]; - } - - /** - * @return array> - */ - protected function searchMembershipProjectsByNamespace(string $ownerPath, string $search): array - { - $matches = []; - $maxPages = 20; // safety cap: 20 * 100 = 2000 membership projects scanned - - for ($page = 1; $page <= $maxPages; $page++) { - $url = "/projects?membership=true&page={$page}&per_page=100"; - if (!empty($search)) { - $url .= "&search=" . urlencode($search); - } - - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); - $statusCode = $response['headers']['status-code'] ?? 0; - $responseBody = $response['body'] ?? []; - - if ($statusCode >= 400 || !is_array($responseBody) || empty($responseBody)) { - break; - } - - foreach ($responseBody as $repo) { - if (($repo['namespace']['path'] ?? '') === $ownerPath) { - $matches[] = $repo; - } + $repositories = []; + foreach ($responseBody as $repo) { + if ($filterByNamespace && ($repo['namespace']['path'] ?? '') !== $ownerPath) { + continue; } - if (\count($responseBody) < 100) { - break; - } + $repositories[] = [ + 'id' => $repo['id'] ?? 0, + 'name' => $repo['name'] ?? '', + 'description' => $repo['description'] ?? '', + 'private' => ($repo['visibility'] ?? '') === 'private', + 'pushed_at' => $repo['last_activity_at'] ?? '', + ]; } - return $matches; - } + // GitLab returns the total count via the X-Total header, not the + // body. When filtering client-side (personal-namespace fallback), + // that header reflects every namespace the token can see, not just + // the requested owner, so the filtered count is used instead. + $total = $filterByNamespace + ? \count($repositories) + : (int) ($responseHeaders['x-total'] ?? \count($repositories)); - /** - * @param array $repo - * @return array - */ - protected function mapSearchedRepository(array $repo): array - { return [ - 'id' => $repo['id'] ?? 0, - 'name' => $repo['name'] ?? '', - 'description' => $repo['description'] ?? '', - 'private' => ($repo['visibility'] ?? '') === 'private', - 'pushed_at' => $repo['last_activity_at'] ?? '', + 'items' => $repositories, + 'total' => $total, ]; } From 1ce9452460e117a482f3bb6d983c591cfe21d6e5 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 17 Jul 2026 15:57:40 +0530 Subject: [PATCH 16/16] Shorten comment on searchRepositories() total count --- src/VCS/Adapter/Git/GitLab.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index b7ba2afd..269d68d2 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -294,10 +294,8 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri ]; } - // GitLab returns the total count via the X-Total header, not the - // body. When filtering client-side (personal-namespace fallback), - // that header reflects every namespace the token can see, not just - // the requested owner, so the filtered count is used instead. + // Total comes from the X-Total header, except when filtering + // client-side, where the header covers every namespace, not just this owner. $total = $filterByNamespace ? \count($repositories) : (int) ($responseHeaders['x-total'] ?? \count($repositories));