From b38bfa5a4dab5f4374919048ac9090b5ba24aba9 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 16 Jul 2026 13:25:56 +0530 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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);