diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 51b20f49..6aab9d96 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,32 +251,45 @@ 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; - // 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); } - $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) { - return []; + return ['items' => [], 'total' => 0]; } $responseBody = $response['body'] ?? []; if (!is_array($responseBody)) { - return []; + return ['items' => [], 'total' => 0]; } $repositories = []; foreach ($responseBody as $repo) { + if ($filterByNamespace && ($repo['namespace']['path'] ?? '') !== $ownerPath) { + continue; + } + $repositories[] = [ 'id' => $repo['id'] ?? 0, 'name' => $repo['name'] ?? '', @@ -286,14 +299,25 @@ 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 { $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; @@ -316,7 +340,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) { @@ -332,7 +356,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; @@ -355,7 +379,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; @@ -393,7 +417,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; @@ -425,7 +449,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; @@ -457,7 +481,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; @@ -474,7 +498,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, ]); @@ -501,7 +525,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; @@ -526,7 +550,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; @@ -545,7 +569,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; @@ -573,7 +597,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'] ?? ''; } @@ -590,7 +614,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) { @@ -604,7 +628,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; @@ -626,7 +650,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) { @@ -638,7 +662,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) { @@ -654,7 +678,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; @@ -690,7 +714,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) { @@ -706,7 +730,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; @@ -740,7 +764,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; @@ -778,7 +802,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) { @@ -806,7 +830,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) { @@ -831,7 +855,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; @@ -857,7 +881,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; @@ -915,7 +939,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; @@ -974,6 +998,15 @@ public function generateCloneCommand(string $owner, string $repositoryName, stri return implode(' && ', $commands); } + // 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', + 'update' => 'synchronize', + 'close' => 'closed', + 'merge' => 'closed', + ]; + public function getEvent(string $event, string $payload): array { $payloadArray = json_decode($payload, true); @@ -983,6 +1016,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 +1027,81 @@ public function getEvent(string $event, string $payload): array } } if (empty($latestCommit) && !empty($commits)) { - $latestCommit = $commits[0]; + $latestCommit = $commits[array_key_last($commits)]; } - $ref = $payloadArray['ref'] ?? ''; - // ref format: refs/heads/main - $branch = str_replace('refs/heads/', '', $ref); + + $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; + } + } + } + + $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 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']; 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: @@ -1064,7 +1133,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; @@ -1081,7 +1150,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; diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 42322e11..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); @@ -719,16 +722,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 +752,63 @@ 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 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 @@ -753,8 +816,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 +827,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 +846,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 +1462,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