From c2c4cb7b8aced53cea29fd495aec54b121f5dd53 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 29 Jul 2026 12:48:25 +0530 Subject: [PATCH 01/34] Add Bitbucket adapter --- .github/workflows/tests-external.yml | 4 +- .github/workflows/tests.yml | 4 +- CONTRIBUTING.md | 9 +- README.md | 7 +- docker-compose.yml | 2 + phpunit.xml | 3 + src/VCS/Adapter/Git/Bitbucket.php | 1344 ++++++++++++++++++++++++++ tests/VCS/Adapter/BitbucketTest.php | 498 ++++++++++ 8 files changed, 1866 insertions(+), 5 deletions(-) create mode 100644 src/VCS/Adapter/Git/Bitbucket.php create mode 100644 tests/VCS/Adapter/BitbucketTest.php diff --git a/.github/workflows/tests-external.yml b/.github/workflows/tests-external.yml index 022b7779..7e21070e 100644 --- a/.github/workflows/tests-external.yml +++ b/.github/workflows/tests-external.yml @@ -17,7 +17,7 @@ jobs: strategy: fail-fast: false matrix: - adapter: [gitea, forgejo, github, gitlab, gogs] + adapter: [gitea, forgejo, github, gitlab, gogs, bitbucket] steps: - name: Check out the repo @@ -30,6 +30,8 @@ jobs: TESTS_GITHUB_PRIVATE_KEY: ${{ secrets.TESTS_GITHUB_PRIVATE_KEY }} TESTS_GITHUB_APP_IDENTIFIER: ${{ secrets.TESTS_GITHUB_APP_IDENTIFIER }} TESTS_GITHUB_INSTALLATION_ID: ${{ secrets.TESTS_GITHUB_INSTALLATION_ID }} + TESTS_BITBUCKET_ACCESS_TOKEN: ${{ secrets.TESTS_BITBUCKET_ACCESS_TOKEN }} + TESTS_BITBUCKET_WORKSPACE: ${{ secrets.TESTS_BITBUCKET_WORKSPACE }} run: | docker compose --profile ${{ matrix.adapter }} up -d --wait --wait-timeout 900 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6bc31165..60a7234f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - adapter: [gitea, forgejo, github, gitlab, gogs] + adapter: [gitea, forgejo, github, gitlab, gogs, bitbucket] steps: - name: Check out the repo @@ -26,6 +26,8 @@ jobs: TESTS_GITHUB_PRIVATE_KEY: ${{ secrets.TESTS_GITHUB_PRIVATE_KEY }} TESTS_GITHUB_APP_IDENTIFIER: ${{ secrets.TESTS_GITHUB_APP_IDENTIFIER }} TESTS_GITHUB_INSTALLATION_ID: ${{ secrets.TESTS_GITHUB_INSTALLATION_ID }} + TESTS_BITBUCKET_ACCESS_TOKEN: ${{ secrets.TESTS_BITBUCKET_ACCESS_TOKEN }} + TESTS_BITBUCKET_WORKSPACE: ${{ secrets.TESTS_BITBUCKET_WORKSPACE }} run: | docker compose --profile ${{ matrix.adapter }} up -d --wait --wait-timeout 900 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6509d457..b5f74cd8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -88,7 +88,7 @@ sleep 15 docker compose exec -T tests vendor/bin/phpunit --configuration phpunit.xml --testsuite ``` -Where `` is one of: `gitea`, `forgejo`, `github`, `gitlab`, `gogs`. +Where `` is one of: `gitea`, `forgejo`, `github`, `gitlab`, `gogs`, `bitbucket`. For example, to run Gitea tests: @@ -100,6 +100,13 @@ docker compose exec -T tests vendor/bin/phpunit --configuration phpunit.xml --te The `github` adapter does not require any local services — only the GitHub secrets (`TESTS_GITHUB_PRIVATE_KEY`, `TESTS_GITHUB_APP_IDENTIFIER`, `TESTS_GITHUB_INSTALLATION_ID`) as environment variables. +The `bitbucket` adapter runs against Bitbucket Cloud, which has no self-hostable image, so it needs credentials instead of a local service: + +- `TESTS_BITBUCKET_ACCESS_TOKEN` — an OAuth 2.0 or workspace access token with read and write access to repositories, pull requests and webhooks +- `TESTS_BITBUCKET_WORKSPACE` — workspace the test repositories are created in; defaults to the token owner's own workspace + +The workspace needs at least one project, since Bitbucket assigns every new repository to one. Without these variables the suite is skipped, the same way the `github` one is. + ## Adding A New Adapter You can follow our [Adding new VCS Adapter](docs/add-new-vcs-adapter.md) tutorial to add a new VCS adapter like GitLab, Bitbucket etc. in this library. diff --git a/README.md b/README.md index d7cc8926..20a01605 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,11 @@ VCS Adapters: | Adapter | Status | |---------|---------| | GitHub | ✅ | -| GitLab | | -| Bitbucket | | +| GitLab | ✅ | +| Gitea | ✅ | +| Forgejo | ✅ | +| Gogs | ✅ | +| Bitbucket | ✅ | | Azure DevOps | | `✅ - supported, 🛠 - work in progress` diff --git a/docker-compose.yml b/docker-compose.yml index 82408000..0fb4932c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,8 @@ services: - TESTS_GITHUB_PRIVATE_KEY - TESTS_GITHUB_APP_IDENTIFIER - TESTS_GITHUB_INSTALLATION_ID + - TESTS_BITBUCKET_ACCESS_TOKEN + - TESTS_BITBUCKET_WORKSPACE - TESTS_GITEA_URL=http://gitea:3000 - TESTS_REQUEST_CATCHER_URL=http://request-catcher:5000 - TESTS_FORGEJO_URL=http://forgejo:3000 diff --git a/phpunit.xml b/phpunit.xml index 121d0d0a..45b40fa2 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -24,5 +24,8 @@ ./tests/VCS/Adapter/GogsTest.php + + ./tests/VCS/Adapter/BitbucketTest.php + \ No newline at end of file diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php new file mode 100644 index 00000000..2a6893de --- /dev/null +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -0,0 +1,1344 @@ + + */ + protected $headers = ['content-type' => 'application/json']; + + /** + * Maps the state vocabulary shared by the other adapters (GitHub's) onto + * Bitbucket's build states. + */ + private const COMMIT_STATE_MAP = [ + 'pending' => 'INPROGRESS', + 'in_progress' => 'INPROGRESS', + 'success' => 'SUCCESSFUL', + 'failure' => 'FAILED', + 'error' => 'FAILED', + 'cancelled' => 'STOPPED', + ]; + + /** + * Reverse of COMMIT_STATE_MAP, so getCommitStatuses() reports states in the + * same vocabulary updateCommitStatus() accepts. + */ + private const COMMIT_STATE_MAP_REVERSE = [ + 'INPROGRESS' => 'pending', + 'SUCCESSFUL' => 'success', + 'FAILED' => 'failure', + 'STOPPED' => 'cancelled', + ]; + + /** + * Maps Bitbucket's event keys to the pull request verbs consumers key off + * of; both a merge and a decline count as 'closed'. + */ + private const PULL_REQUEST_ACTION_MAP = [ + 'pullrequest:created' => 'opened', + 'pullrequest:updated' => 'synchronize', + 'pullrequest:fulfilled' => 'closed', + 'pullrequest:rejected' => 'closed', + ]; + + public function __construct(Cache $cache) + { + $this->cache = $cache; + } + + /** + * Point the adapter at a different API base, e.g. a proxy in front of + * Bitbucket Cloud. Expects a full base URL such as + * 'https://api.bitbucket.org/2.0'. + */ + public function setEndpoint(string $endpoint): void + { + $this->endpoint = rtrim($endpoint, '/'); + } + + /** + * Point the adapter at a different browser-facing host, e.g. + * 'https://bitbucket.org'. + */ + public function setBitbucketUrl(string $bitbucketUrl): void + { + $this->bitbucketUrl = rtrim($bitbucketUrl, '/'); + } + + public function getName(): string + { + return 'bitbucket'; + } + + public function getEventHeaderName(): string + { + return 'x-event-key'; + } + + public function getSignatureHeaderName(): string + { + return 'x-hub-signature'; + } + + public function getSupportedWebhookScopes(): array + { + return [self::WEBHOOK_SCOPE_REPOSITORY]; + } + + public function getRepositoryUrl(string $owner, string $repositoryName): string + { + return "{$this->bitbucketUrl}/{$owner}/{$repositoryName}"; + } + + public function getBranchUrl(string $owner, string $repositoryName, string $branch): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/branch/{$branch}"; + } + + public function getCommitUrl(string $owner, string $repositoryName, string $commitHash): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/commits/{$commitHash}"; + } + + public function getFileUrl(string $owner, string $repositoryName, string $reference): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/src/{$reference}"; + } + + /** + * Bitbucket has no app installation flow; it authenticates with an OAuth 2.0 + * access token (or a workspace/repository access token), passed as + * $accessToken. $installationId, $privateKey and $appId are unused. + */ + public function initializeVariables(string $installationId, string $privateKey, ?string $appId = null, ?string $accessToken = null, ?string $refreshToken = null): void + { + if (!empty($accessToken)) { + $this->accessToken = $accessToken; + $this->refreshToken = $refreshToken; + + return; + } + + throw new Exception("accessToken is required for this adapter."); + } + + /** + * Not applicable for this adapter - OAuth2 tokens are passed directly. + */ + protected function generateAccessToken(string $privateKey, string $appId): void + { + return; + } + + /** + * Bitbucket paths are literal URL segments, so unlike GitHub it never + * resolves './' or '.' to the repository root on its own. + */ + private function normalizeRepositoryPath(string $path): string + { + $segments = array_filter( + explode('/', $path), + fn (string $segment): bool => $segment !== '' && $segment !== '.' + ); + + return implode('/', $segments); + } + + /** + * Encode a repository path for use in a URL while keeping its separators. + */ + private function encodeRepositoryPath(string $path): string + { + $segments = array_map('rawurlencode', explode('/', $path)); + + return implode('/', $segments); + } + + /** + * Bitbucket's source endpoints always want an explicit ref, so fall back to + * the repository's main branch when the caller didn't name one. + */ + private function resolveRef(string $owner, string $repositoryName, string $ref): string + { + if (!empty($ref)) { + return $ref; + } + + $repository = $this->getRepository($owner, $repositoryName); + $mainbranch = $repository['mainbranch'] ?? []; + $name = is_array($mainbranch) ? ($mainbranch['name'] ?? '') : ''; + + if (empty($name)) { + throw new Exception("Unable to resolve the main branch of {$owner}/{$repositoryName}."); + } + + return (string) $name; + } + + /** + * Repository responses carry Bitbucket's own field names; surface the keys + * the other adapters report under so consumers can treat them alike. + * + * @param array $repository + * @return array + */ + private function normalizeRepository(array $repository): array + { + $fullName = (string) ($repository['full_name'] ?? ''); + + // Bitbucket has no numeric repository ids; "workspace/slug" is the + // identifier its API routes on, so that is what getRepositoryName() + // and getOwnerName() expect to receive back. + $repository['id'] = $fullName; + $repository['private'] = ($repository['is_private'] ?? false) === true; + $repository['pushed_at'] = $repository['updated_on'] ?? ''; + + if (empty($repository['workspace']['slug']) && strpos($fullName, '/') !== false) { + $repository['workspace'] = ['slug' => explode('/', $fullName)[0]]; + } + + return $repository; + } + + public function createRepository(string $owner, string $repositoryName, bool $private): array + { + $url = "/repositories/{$owner}/{$repositoryName}"; + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [ + 'scm' => 'git', + 'name' => $repositoryName, + 'is_private' => $private, + ]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Creating repository {$repositoryName} failed with status code {$statusCode}", $statusCode); + } + + $body = $response['body'] ?? []; + + return $this->normalizeRepository(is_array($body) ? $body : []); + } + + public function deleteRepository(string $owner, string $repositoryName): bool + { + $url = "/repositories/{$owner}/{$repositoryName}"; + + $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Deleting repository {$repositoryName} failed with status code {$statusCode}", $statusCode); + } + + return true; + } + + public function getRepository(string $owner, string $repositoryName): array + { + $url = "/repositories/{$owner}/{$repositoryName}"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new RepositoryNotFound("Repository not found"); + } + + $body = $response['body'] ?? []; + + return $this->normalizeRepository(is_array($body) ? $body : []); + } + + public function getRepositoryName(string $repositoryId): string + { + // Bitbucket has no numeric repository ids, so $repositoryId is the + // "workspace/slug" pair reported as `id` by createRepository(). + $url = "/repositories/{$repositoryId}"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new RepositoryNotFound("Repository {$repositoryId} not found"); + } + + $responseBody = $response['body'] ?? []; + + return $responseBody['name'] ?? ''; + } + + public function hasAccessToAllRepositories(): bool + { + return true; + } + + public function getInstallationRepository(string $repositoryName): array + { + throw new Exception("getInstallationRepository is not applicable for this adapter"); + } + + public function searchRepositories(string $owner, int $page, int $per_page, string $search = ''): array + { + $url = "/repositories/{$owner}?page={$page}&pagelen={$per_page}"; + + if (!empty($search)) { + // Bitbucket's filter grammar quotes string literals, so escape the + // characters that would otherwise break out of the quoted value. + $escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $search); + $url .= '&q=' . urlencode("name~\"{$escaped}\""); + } + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + return ['items' => [], 'total' => 0]; + } + + $responseBody = $response['body'] ?? []; + if (!is_array($responseBody)) { + return ['items' => [], 'total' => 0]; + } + + $repositories = []; + foreach (($responseBody['values'] ?? []) as $repository) { + $repositories[] = [ + 'id' => $repository['full_name'] ?? '', + 'name' => $repository['name'] ?? '', + 'description' => $repository['description'] ?? '', + 'private' => ($repository['is_private'] ?? false) === true, + 'pushed_at' => $repository['updated_on'] ?? '', + ]; + } + + // `size` is optional on Bitbucket's paginated responses, so fall back to + // what this page actually carried. + return [ + 'items' => $repositories, + 'total' => (int) ($responseBody['size'] ?? \count($repositories)), + ]; + } + + public function getRepositoryTree(string $owner, string $repositoryName, string $branch, bool $recursive = false): array + { + $suffix = $recursive ? '&max_depth=' . self::MAX_TREE_DEPTH : ''; + + $items = $this->listSource($owner, $repositoryName, '', $branch, $suffix); + + return array_column($items, 'path'); + } + + public function listRepositoryContents(string $owner, string $repositoryName, string $path = '', string $ref = ''): array + { + $items = $this->listSource($owner, $repositoryName, $path, $ref); + + $contents = []; + foreach ($items as $item) { + $itemPath = (string) ($item['path'] ?? ''); + $contents[] = [ + 'name' => basename($itemPath), + 'size' => $item['size'] ?? 0, + 'type' => ($item['type'] ?? '') === 'commit_directory' ? self::CONTENTS_DIRECTORY : self::CONTENTS_FILE, + ]; + } + + return $contents; + } + + /** + * List entries of a directory in the repository, following pagination. + * Returns an empty list when the ref or path doesn't exist. + * + * @param string $suffix Extra query string to append, e.g. '&max_depth=100' + * @return array + */ + private function listSource(string $owner, string $repositoryName, string $path, string $ref, string $suffix = ''): array + { + try { + $ref = $this->resolveRef($owner, $repositoryName, $ref); + } catch (Exception $e) { + return []; + } + + $path = $this->normalizeRepositoryPath($path); + $base = "/repositories/{$owner}/{$repositoryName}/src/" . rawurlencode($ref) . '/' . $this->encodeRepositoryPath($path); + + $items = []; + $page = 1; + do { + $url = $base . '?pagelen=' . self::PAGE_SIZE . "&page={$page}" . $suffix; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + return $page === 1 ? [] : $items; + } + + $responseBody = $response['body'] ?? []; + if (!is_array($responseBody)) { + break; + } + + $values = $responseBody['values'] ?? []; + if (!is_array($values)) { + break; + } + + $items = array_merge($items, $values); + $page++; + } while (!empty($responseBody['next'])); + + return $items; + } + + public function getRepositoryContent(string $owner, string $repositoryName, string $path, string $ref = ''): array + { + try { + $ref = $this->resolveRef($owner, $repositoryName, $ref); + } catch (Exception $e) { + throw new FileNotFound(); + } + + $path = $this->normalizeRepositoryPath($path); + $url = "/repositories/{$owner}/{$repositoryName}/src/" . rawurlencode($ref) . '/' . $this->encodeRepositoryPath($path); + + $metaResponse = $this->call(self::METHOD_GET, $url . '?format=meta', ['Authorization' => 'Bearer ' . $this->accessToken]); + + $metaHeaders = $metaResponse['headers'] ?? []; + if (($metaHeaders['status-code'] ?? 0) !== 200) { + throw new FileNotFound(); + } + + $meta = $metaResponse['body'] ?? []; + if (!is_array($meta) || ($meta['type'] ?? '') !== 'commit_file') { + throw new FileNotFound(); + } + + // Bitbucket serves file contents raw, typed after the file extension, so + // don't let the response be decoded as JSON. + $contentResponse = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [], false); + + $contentHeaders = $contentResponse['headers'] ?? []; + if (($contentHeaders['status-code'] ?? 0) !== 200) { + throw new FileNotFound(); + } + + $content = $contentResponse['body'] ?? ''; + $content = is_string($content) ? $content : ''; + + $commit = $meta['commit'] ?? []; + + return [ + // Bitbucket exposes no blob id, so report the commit the file was + // last changed in — the closest stable identifier it gives us. + 'sha' => is_array($commit) ? ($commit['hash'] ?? '') : '', + 'size' => $meta['size'] ?? \strlen($content), + 'content' => $content, + ]; + } + + /** + * Bitbucket doesn't compute language statistics; a repository carries a + * single, manually set `language` field, which this reports when present. + */ + public function listRepositoryLanguages(string $owner, string $repositoryName): array + { + $repository = $this->getRepository($owner, $repositoryName); + + $language = (string) ($repository['language'] ?? ''); + + return empty($language) ? [] : [$language]; + } + + public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file', string $branch = ''): array + { + if (empty($branch)) { + $branch = $this->resolveDefaultBranch($owner, $repositoryName); + } + + $url = "/repositories/{$owner}/{$repositoryName}/src"; + + // File paths are sent as form field names. A leading slash keeps a file + // called e.g. 'message' from being read as commit metadata; Bitbucket + // treats every path as absolute from the repository root either way. + $payload = [ + 'message' => $message, + 'branch' => $branch, + '/' . $this->normalizeRepositoryPath($filepath) => $content, + ]; + + $response = $this->call( + self::METHOD_POST, + $url, + [ + 'Authorization' => 'Bearer ' . $this->accessToken, + 'content-type' => 'application/x-www-form-urlencoded', + ], + $payload + ); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to create file {$filepath}: HTTP {$statusCode}", $statusCode); + } + + // The response body is empty; the new commit is named by the Location header. + $location = (string) ($responseHeaders['location'] ?? ''); + $commitHash = ''; + if (\preg_match('#/commit/([0-9a-f]+)#', $location, $matches) === 1) { + $commitHash = $matches[1]; + } + + return [ + 'path' => $filepath, + 'branch' => $branch, + 'commitHash' => $commitHash, + ]; + } + + /** + * Name of the branch a commit lands on when the caller didn't pick one. + * An empty repository has no main branch yet, and Bitbucket names the + * branch of its root commit after whatever we ask for, so default to 'main'. + */ + private function resolveDefaultBranch(string $owner, string $repositoryName): string + { + $repository = $this->getRepository($owner, $repositoryName); + $mainbranch = $repository['mainbranch'] ?? []; + $name = is_array($mainbranch) ? ($mainbranch['name'] ?? '') : ''; + + return empty($name) ? 'main' : (string) $name; + } + + public function createBranch(string $owner, string $repositoryName, string $newBranchName, string $oldBranchName): array + { + $url = "/repositories/{$owner}/{$repositoryName}/refs/branches"; + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [ + 'name' => $newBranchName, + 'target' => ['hash' => $oldBranchName], + ]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to create branch {$newBranchName}: HTTP {$statusCode}", $statusCode); + } + + return $response['body'] ?? []; + } + + public function listBranches(string $owner, string $repositoryName): array + { + return $this->listRefs($owner, $repositoryName, 'branches'); + } + + public function listTags(string $owner, string $repositoryName, string $search = ''): array + { + return $this->matchGlob($this->listRefs($owner, $repositoryName, 'tags'), $search); + } + + /** + * @param string $type 'branches' or 'tags' + * @return array + */ + private function listRefs(string $owner, string $repositoryName, string $type): array + { + $names = []; + $page = 1; + do { + $url = "/repositories/{$owner}/{$repositoryName}/refs/{$type}?pagelen=" . self::PAGE_SIZE . "&page={$page}"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + return $page === 1 ? [] : $names; + } + + $responseBody = $response['body'] ?? []; + if (!is_array($responseBody)) { + break; + } + + $values = $responseBody['values'] ?? []; + if (!is_array($values)) { + break; + } + + foreach ($values as $ref) { + $names[] = $ref['name'] ?? ''; + } + + $page++; + } while (!empty($responseBody['next'])); + + return $names; + } + + public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array + { + $url = "/repositories/{$owner}/{$repositoryName}/refs/tags"; + + $payload = [ + 'name' => $tagName, + 'target' => ['hash' => $target], + ]; + + if (!empty($message)) { + $payload['message'] = $message; + } + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], $payload); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to create tag {$tagName}: HTTP {$statusCode}", $statusCode); + } + + return $response['body'] ?? []; + } + + public function getCommit(string $owner, string $repositoryName, string $commitHash): array + { + $url = "/repositories/{$owner}/{$repositoryName}/commit/" . rawurlencode($commitHash); + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Commit not found or inaccessible"); + } + + $commit = $response['body'] ?? []; + + return $this->parseCommit(is_array($commit) ? $commit : []); + } + + public function getLatestCommit(string $owner, string $repositoryName, string $branch): array + { + $url = "/repositories/{$owner}/{$repositoryName}/commits/" . rawurlencode($branch) . '?pagelen=1'; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get latest commit: HTTP {$statusCode}", $statusCode); + } + + $responseBody = $response['body'] ?? []; + $values = is_array($responseBody) ? ($responseBody['values'] ?? []) : []; + + if (empty($values[0])) { + throw new Exception("Latest commit response is missing required information."); + } + + return $this->parseCommit($values[0]); + } + + /** + * Normalize a Bitbucket commit into the shape every adapter reports. + * + * @param array $commit + * @return array + */ + private function parseCommit(array $commit): array + { + $author = $commit['author'] ?? []; + $author = is_array($author) ? $author : []; + $user = $author['user'] ?? []; + $user = is_array($user) ? $user : []; + $userLinks = is_array($user['links'] ?? null) ? $user['links'] : []; + + // Unlinked authors are only described by a raw "Name " string. + $name = $user['display_name'] ?? ''; + if (empty($name)) { + $name = \trim(\preg_replace('/<[^>]*>/', '', (string) ($author['raw'] ?? '')) ?? ''); + } + + return [ + 'commitAuthor' => empty($name) ? 'Unknown' : $name, + 'commitMessage' => $commit['message'] ?? 'No message', + 'commitHash' => $commit['hash'] ?? '', + 'commitUrl' => $commit['links']['html']['href'] ?? '', + 'commitAuthorAvatar' => $userLinks['avatar']['href'] ?? '', + 'commitAuthorUrl' => $userLinks['html']['href'] ?? '', + ]; + } + + public function updateCommitStatus(string $repositoryName, string $SHA, string $owner, string $state, string $description = '', string $target_url = '', string $context = ''): void + { + $url = "/repositories/{$owner}/{$repositoryName}/commit/" . rawurlencode($SHA) . '/statuses/build'; + + // Bitbucket identifies a status by its key and overwrites a status + // posted under a key it already has, so the context doubles as the key. + $key = empty($context) ? $this->getName() : $context; + + $payload = [ + 'key' => $key, + 'name' => $key, + 'state' => self::COMMIT_STATE_MAP[$state] ?? $state, + // A build status without a URL is rejected, so point at the commit + // itself when the caller has nowhere better to link. + 'url' => empty($target_url) ? $this->getCommitUrl($owner, $repositoryName, $SHA) : $target_url, + ]; + + if (!empty($description)) { + $payload['description'] = $description; + } + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], $payload); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to update commit status: HTTP {$statusCode}", $statusCode); + } + } + + public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array + { + $url = "/repositories/{$owner}/{$repositoryName}/commit/" . rawurlencode($commitHash) . '/statuses?pagelen=' . self::PAGE_SIZE; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + return []; + } + + $responseBody = $response['body'] ?? []; + if (!is_array($responseBody)) { + return []; + } + + $statuses = []; + foreach (($responseBody['values'] ?? []) as $status) { + $state = (string) ($status['state'] ?? ''); + $statuses[] = [ + 'state' => self::COMMIT_STATE_MAP_REVERSE[$state] ?? $state, + 'description' => $status['description'] ?? '', + 'target_url' => $status['url'] ?? '', + 'context' => $status['key'] ?? '', + ]; + } + + return $statuses; + } + + public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array + { + $url = "/repositories/{$owner}/{$repositoryName}/pullrequests"; + + $payload = [ + 'title' => $title, + 'source' => ['branch' => ['name' => $head]], + 'destination' => ['branch' => ['name' => $base]], + ]; + + if (!empty($body)) { + $payload['description'] = $body; + } + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], $payload); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to create pull request: HTTP {$statusCode}", $statusCode); + } + + $responseBody = $response['body'] ?? []; + $responseBody = is_array($responseBody) ? $responseBody : []; + + // Bitbucket calls it `id`; report it under `number` too, as the other + // adapters do. + $responseBody['number'] = $responseBody['id'] ?? 0; + + return $responseBody; + } + + public function getPullRequest(string $owner, string $repositoryName, int $pullRequestNumber): array + { + $url = "/repositories/{$owner}/{$repositoryName}/pullrequests/{$pullRequestNumber}"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get pull request: HTTP {$statusCode}", $statusCode); + } + + $pullRequest = $response['body'] ?? []; + + return $this->parsePullRequest(is_array($pullRequest) ? $pullRequest : []); + } + + public function getPullRequestFromBranch(string $owner, string $repositoryName, string $branch): array + { + $query = urlencode("source.branch.name=\"{$branch}\""); + $url = "/repositories/{$owner}/{$repositoryName}/pullrequests?state=OPEN&q={$query}"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to list pull requests: HTTP {$statusCode}", $statusCode); + } + + $responseBody = $response['body'] ?? []; + $values = is_array($responseBody) ? ($responseBody['values'] ?? []) : []; + + if (empty($values[0])) { + return []; + } + + return $this->parsePullRequest($values[0]); + } + + /** + * Normalize a Bitbucket pull request into the shape every adapter reports. + * + * @param array $pullRequest + * @return array + */ + private function parsePullRequest(array $pullRequest): array + { + $source = is_array($pullRequest['source'] ?? null) ? $pullRequest['source'] : []; + $destination = is_array($pullRequest['destination'] ?? null) ? $pullRequest['destination'] : []; + + return [ + 'number' => $pullRequest['id'] ?? 0, + 'title' => $pullRequest['title'] ?? '', + // Bitbucket reports OPEN/MERGED/DECLINED/SUPERSEDED + 'state' => \strtolower((string) ($pullRequest['state'] ?? '')), + 'head' => [ + 'ref' => $source['branch']['name'] ?? '', + 'sha' => $source['commit']['hash'] ?? '', + ], + 'base' => [ + 'ref' => $destination['branch']['name'] ?? '', + ], + ]; + } + + public function getPullRequestFiles(string $owner, string $repositoryName, int $pullRequestNumber): array + { + $files = []; + $page = 1; + do { + $url = "/repositories/{$owner}/{$repositoryName}/pullrequests/{$pullRequestNumber}/diffstat?pagelen=" . self::PAGE_SIZE . "&page={$page}"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get pull request files: HTTP {$statusCode}", $statusCode); + } + + $responseBody = $response['body'] ?? []; + if (!is_array($responseBody)) { + break; + } + + $values = $responseBody['values'] ?? []; + if (!is_array($values)) { + break; + } + + foreach ($values as $diff) { + $new = is_array($diff['new'] ?? null) ? $diff['new'] : []; + $old = is_array($diff['old'] ?? null) ? $diff['old'] : []; + + $files[] = [ + 'filename' => $new['path'] ?? $old['path'] ?? '', + ]; + } + + $page++; + } while (!empty($responseBody['next'])); + + return $files; + } + + public function createComment(string $owner, string $repositoryName, int $pullRequestNumber, string $comment): string + { + $url = "/repositories/{$owner}/{$repositoryName}/pullrequests/{$pullRequestNumber}/comments"; + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [ + 'content' => ['raw' => $comment], + ]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to create comment: HTTP {$statusCode}", $statusCode); + } + + $responseBody = $response['body'] ?? []; + if (!is_array($responseBody) || !array_key_exists('id', $responseBody)) { + throw new Exception("Comment creation response is missing comment ID."); + } + + // Comment ids are only addressable through their pull request, so carry both. + return $pullRequestNumber . ':' . $responseBody['id']; + } + + public function getComment(string $owner, string $repositoryName, string $commentId): string + { + $parts = explode(':', $commentId, 2); + if (count($parts) !== 2) { + return ''; + } + + [$pullRequestNumber, $id] = $parts; + $url = "/repositories/{$owner}/{$repositoryName}/pullrequests/{$pullRequestNumber}/comments/{$id}"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + return $response['body']['content']['raw'] ?? ''; + } + + public function updateComment(string $owner, string $repositoryName, string $commentId, string $comment): string + { + $parts = explode(':', $commentId, 2); + if (count($parts) !== 2) { + throw new Exception("Invalid comment ID format: {$commentId}"); + } + + [$pullRequestNumber, $id] = $parts; + $url = "/repositories/{$owner}/{$repositoryName}/pullrequests/{$pullRequestNumber}/comments/{$id}"; + + $response = $this->call(self::METHOD_PUT, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [ + 'content' => ['raw' => $comment], + ]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to update comment: HTTP {$statusCode}", $statusCode); + } + + return $commentId; + } + + /** + * Bitbucket identifies webhooks by UUID, which has no integer form, so this + * reports 0 on success. Use createRepositoryWebhook() to get the UUID + * needed to address the hook later. + */ + public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int + { + $this->createRepositoryWebhook($owner, $repositoryName, $url, $secret, $events); + + return 0; + } + + /** + * Create a webhook on a repository and return Bitbucket's UUID for it. + * + * @param array $events Event names, either this library's ('push', + * 'pull_request') or Bitbucket's own keys + * (e.g. 'repo:push') + */ + public function createRepositoryWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): string + { + $apiUrl = "/repositories/{$owner}/{$repositoryName}/hooks"; + + $payload = [ + 'description' => 'utopia', + 'url' => $url, + 'active' => true, + 'events' => $this->mapWebhookEvents($events), + ]; + + if (!empty($secret)) { + $payload['secret'] = $secret; + } + + $response = $this->call(self::METHOD_POST, $apiUrl, ['Authorization' => 'Bearer ' . $this->accessToken], $payload); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to create webhook: HTTP {$statusCode} - " . json_encode($response['body'] ?? []), $statusCode); + } + + $responseBody = $response['body'] ?? []; + + return $responseBody['uuid'] ?? ''; + } + + /** + * Delete a webhook from a repository. + */ + public function deleteWebhook(string $owner, string $repositoryName, string $webhookUuid): bool + { + $url = "/repositories/{$owner}/{$repositoryName}/hooks/" . rawurlencode($webhookUuid); + + $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to delete webhook: HTTP {$statusCode}", $statusCode); + } + + return true; + } + + /** + * Translate this library's event names into Bitbucket's event keys. A pull + * request maps to several of them, since Bitbucket splits open, update, + * merge and decline into separate events. + * + * @param array $events + * @return array + */ + private function mapWebhookEvents(array $events): array + { + $keys = []; + foreach ($events as $event) { + // Native Bitbucket keys pass through untouched + if (\strpos($event, ':') !== false) { + $keys[] = $event; + continue; + } + + $keys = match ($event) { + 'push' => \array_merge($keys, ['repo:push']), + 'pull_request' => \array_merge($keys, \array_keys(self::PULL_REQUEST_ACTION_MAP)), + default => $keys, + }; + } + + return \array_values(\array_unique($keys)); + } + + public function getUser(string $username): array + { + // Bitbucket looks accounts up by UUID or Atlassian account id; only some + // accounts still resolve by name. + $url = '/users/' . rawurlencode($username); + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get user: HTTP {$statusCode}", $statusCode); + } + + $body = $response['body'] ?? []; + if (!is_array($body) || empty($body['uuid'])) { + throw new Exception("User not found: {$username}"); + } + + // Bitbucket has no numeric user ids, and reports the handle as + // `nickname`; surface both under the shared keys. + $body['id'] = $body['uuid']; + $body['username'] = $body['username'] ?? ($body['nickname'] ?? ''); + + return $body; + } + + /** + * Account the access token belongs to. + * + * @return array + */ + public function getAuthenticatedUser(): array + { + $response = $this->call(self::METHOD_GET, '/user', ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get current user: HTTP {$statusCode}", $statusCode); + } + + $body = $response['body'] ?? []; + + return is_array($body) ? $body : []; + } + + /** + * Workspaces the access token can act on. + * + * @return array{items: array, total: int} + */ + public function listWorkspaces(int $page, int $per_page): array + { + $url = "/workspaces?page={$page}&pagelen={$per_page}"; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to list workspaces: HTTP {$statusCode}", $statusCode); + } + + $responseBody = $response['body'] ?? []; + $values = is_array($responseBody) ? ($responseBody['values'] ?? []) : []; + + $workspaces = []; + foreach ($values as $workspace) { + $workspaces[] = [ + 'id' => (string) ($workspace['uuid'] ?? ''), + 'name' => $workspace['name'] ?? ($workspace['slug'] ?? ''), + 'slug' => $workspace['slug'] ?? '', + ]; + } + + return [ + 'items' => $workspaces, + 'total' => (int) ($responseBody['size'] ?? \count($workspaces)), + ]; + } + + /** + * Bitbucket has no installations and no numeric repository ids, so + * $installationId and $repositoryId are both unused: the owner is always + * the workspace of the account the token belongs to. + */ + public function getOwnerName(string $installationId, ?int $repositoryId = null): string + { + $user = $this->getAuthenticatedUser(); + + // A personal workspace is named after its account. `username` is only + // reported for the authenticated account itself, hence the fallback. + return (string) ($user['username'] ?? ($user['nickname'] ?? '')); + } + + public function generateCloneCommand(string $owner, string $repositoryName, string $version, string $versionType, string $directory, string $rootDirectory): string + { + if (empty($rootDirectory) || $rootDirectory === '/') { + $rootDirectory = '*'; + } + + // Bitbucket clone URL format: https://x-token-auth:{token}@host/owner/repo.git + $baseUrl = $this->bitbucketUrl; + if (!empty($this->accessToken)) { + $baseUrl = str_replace('://', '://x-token-auth:' . urlencode($this->accessToken) . '@', $this->bitbucketUrl); + } + + $cloneUrl = escapeshellarg("{$baseUrl}/{$owner}/{$repositoryName}.git"); + $directory = escapeshellarg($directory); + $rootDirectory = escapeshellarg($rootDirectory); + + $commands = [ + "mkdir -p {$directory}", + "cd {$directory}", + "git config --global init.defaultBranch main", + "git init", + "git remote add origin {$cloneUrl}", + "git config core.sparseCheckout true", + "echo {$rootDirectory} >> .git/info/sparse-checkout", + "git config --add remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'", + "git config remote.origin.tagopt --no-tags", + ]; + + switch ($versionType) { + case self::CLONE_TYPE_BRANCH: + $branchName = escapeshellarg($version); + $commands[] = "if git ls-remote --exit-code --heads origin {$branchName}; then git pull --depth=1 origin {$branchName} && git checkout {$branchName}; else git checkout -b {$branchName}; fi"; + break; + case self::CLONE_TYPE_COMMIT: + $commitHash = escapeshellarg($version); + $commands[] = "git fetch --depth=1 origin {$commitHash} && git checkout {$commitHash}"; + break; + case self::CLONE_TYPE_TAG: + $tagName = escapeshellarg($version); + $commands[] = "git fetch --depth=1 origin refs/tags/{$tagName} && git checkout FETCH_HEAD"; + break; + default: + throw new Exception("Unsupported clone type: {$versionType}"); + } + + return implode(' && ', $commands); + } + + public function getEvent(string $event, string $payload): array + { + $payloadArray = json_decode($payload, true); + if ($payloadArray === null || !is_array($payloadArray)) { + return []; + } + + $repository = is_array($payloadArray['repository'] ?? null) ? $payloadArray['repository'] : []; + $actor = is_array($payloadArray['actor'] ?? null) ? $payloadArray['actor'] : []; + $actorLinks = is_array($actor['links'] ?? null) ? $actor['links'] : []; + + $repositoryId = strval($repository['uuid'] ?? ''); + $repositoryName = $repository['name'] ?? ''; + $repositoryUrl = $repository['links']['html']['href'] ?? ''; + $workspace = is_array($repository['workspace'] ?? null) ? $repository['workspace'] : []; + $owner = $workspace['slug'] ?? ''; + if (empty($owner) && strpos((string) ($repository['full_name'] ?? ''), '/') !== false) { + $owner = explode('/', (string) $repository['full_name'])[0]; + } + + switch ($event) { + case 'repo:push': + $push = is_array($payloadArray['push'] ?? null) ? $payloadArray['push'] : []; + $changes = is_array($push['changes'] ?? null) ? $push['changes'] : []; + + // A push that advances several refs at once is reported as one + // event with one change per ref. The shared event shape only + // carries a single branch, so report the first change, matching + // how the other adapters report a single ref per push. + $change = is_array($changes[0] ?? null) ? $changes[0] : []; + + $new = is_array($change['new'] ?? null) ? $change['new'] : []; + $old = is_array($change['old'] ?? null) ? $change['old'] : []; + + // A deleted branch is reported as a change with no new state + $branch = $new['name'] ?? ($old['name'] ?? ''); + $target = is_array($new['target'] ?? null) ? $new['target'] : []; + $author = is_array($target['author'] ?? null) ? $target['author'] : []; + $raw = (string) ($author['raw'] ?? ''); + + $authorName = $author['user']['display_name'] ?? ''; + if (empty($authorName)) { + $authorName = \trim(\preg_replace('/<[^>]*>/', '', $raw) ?? ''); + } + + $authorEmail = ''; + if (\preg_match('/<([^>]*)>/', $raw, $matches) === 1) { + $authorEmail = $matches[1]; + } + + return [ + 'branchCreated' => ($change['created'] ?? false) === true, + 'branchDeleted' => ($change['closed'] ?? false) === true, + 'branch' => $branch, + 'branchUrl' => !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . '/branch/' . $branch : '', + 'repositoryId' => $repositoryId, + 'repositoryName' => $repositoryName, + 'repositoryUrl' => $repositoryUrl, + 'installationId' => '', // Bitbucket has no installations + 'commitHash' => $target['hash'] ?? '', + 'owner' => $owner, + 'authorUrl' => $actorLinks['html']['href'] ?? '', + 'authorAvatarUrl' => $actorLinks['avatar']['href'] ?? '', + 'headCommitAuthorName' => $authorName, + 'headCommitAuthorEmail' => $authorEmail, + 'headCommitMessage' => $target['message'] ?? '', + 'headCommitUrl' => $target['links']['html']['href'] ?? '', + 'external' => false, + 'pullRequestNumber' => '', + 'action' => '', + // Bitbucket's push payload carries no per-commit file lists + 'affectedFiles' => [], + ]; + + case 'pullrequest:created': + case 'pullrequest:updated': + case 'pullrequest:fulfilled': + case 'pullrequest:rejected': + $pullRequest = is_array($payloadArray['pullrequest'] ?? null) ? $payloadArray['pullrequest'] : []; + $source = is_array($pullRequest['source'] ?? null) ? $pullRequest['source'] : []; + $destination = is_array($pullRequest['destination'] ?? null) ? $pullRequest['destination'] : []; + + $branch = $source['branch']['name'] ?? ''; + $commitHash = $source['commit']['hash'] ?? ''; + + // A pull request whose source lives in another repository is a + // fork-based external contribution; defaults to false + // (intentional) if either uuid is missing. + $sourceRepositoryId = $source['repository']['uuid'] ?? null; + $destinationRepositoryId = $destination['repository']['uuid'] ?? null; + $external = $sourceRepositoryId !== null + && $destinationRepositoryId !== null + && $sourceRepositoryId !== $destinationRepositoryId; + + return [ + 'branch' => $branch, + 'branchUrl' => !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . '/branch/' . $branch : '', + 'repositoryId' => $repositoryId, + 'repositoryName' => $repositoryName, + 'repositoryUrl' => $repositoryUrl, + 'installationId' => '', + 'commitHash' => $commitHash, + 'owner' => $owner, + 'authorUrl' => $actorLinks['html']['href'] ?? '', + 'authorAvatarUrl' => $actorLinks['avatar']['href'] ?? '', + 'headCommitUrl' => !empty($repositoryUrl) && !empty($commitHash) ? $repositoryUrl . '/commits/' . $commitHash : '', + 'external' => $external, + 'pullRequestNumber' => $pullRequest['id'] ?? '', + 'action' => self::PULL_REQUEST_ACTION_MAP[$event], + ]; + + default: + return []; + } + } + + /** + * Bitbucket prefixes the digest with the algorithm it used, as GitHub does. + */ + public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool + { + $expected = 'sha256=' . hash_hmac('sha256', $payload, $signatureKey); + + return hash_equals($expected, $signature); + } +} diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php new file mode 100644 index 00000000..89470b5f --- /dev/null +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -0,0 +1,498 @@ +markTestSkipped('Bitbucket access token not configured'); + } + + $adapter = new Bitbucket(new Cache(new None())); + $adapter->initializeVariables( + installationId: '', + privateKey: '', + appId: '', + accessToken: static::$accessToken, + refreshToken: '' + ); + + $endpoint = System::getEnv('TESTS_BITBUCKET_ENDPOINT') ?? ''; + if (!empty($endpoint)) { + $adapter->setEndpoint($endpoint); + } + + if (empty(static::$owner)) { + // Fall back to the token's own workspace when none is configured + static::$owner = System::getEnv('TESTS_BITBUCKET_WORKSPACE') ?: $adapter->getOwnerName(''); + } + + $this->vcsAdapter = $adapter; + } + + public function testWebhookHeaderNames(): void + { + $this->assertSame('x-event-key', $this->vcsAdapter->getEventHeaderName()); + $this->assertSame('x-hub-signature', $this->vcsAdapter->getSignatureHeaderName()); + } + + /** + * Bitbucket has no numeric repository ids, so the owner always resolves from + * the account the token belongs to rather than from a repository. + */ + public function testGetOwnerName(): void + { + $owner = $this->vcsAdapter->getOwnerName(''); + + $this->assertIsString($owner); + $this->assertNotEmpty($owner); + $this->assertSame($owner, $this->vcsAdapter->getOwnerName('', 12345)); + } + + /** + * Bitbucket looks accounts up by UUID rather than by handle. + */ + public function testGetUser(): void + { + /** @var Bitbucket $adapter */ + $adapter = $this->vcsAdapter; + + $me = $adapter->getAuthenticatedUser(); + $this->assertNotEmpty($me['uuid'] ?? ''); + + $result = $adapter->getUser($me['uuid']); + + $this->assertIsArray($result); + $this->assertSame($me['uuid'], $result['id']); + // Bitbucket reports the handle as `nickname`, and `username` only for + // the authenticated account itself + $this->assertSame($me['username'] ?? ($me['nickname'] ?? ''), $result['username']); + } + + public function testListRepositoryLanguages(): void + { + $this->markTestSkipped('Bitbucket does not compute language statistics; the language field is set by hand'); + } + + public function testListWorkspaces(): void + { + /** @var Bitbucket $adapter */ + $adapter = $this->vcsAdapter; + + $result = $adapter->listWorkspaces(1, 20); + + $this->assertIsArray($result); + $this->assertArrayHasKey('items', $result); + $this->assertArrayHasKey('total', $result); + $this->assertNotEmpty($result['items']); + + foreach ($result['items'] as $workspace) { + $this->assertArrayHasKey('id', $workspace); + $this->assertArrayHasKey('name', $workspace); + $this->assertArrayHasKey('slug', $workspace); + $this->assertNotEmpty($workspace['slug']); + } + } + + public function testSearchRepositoriesWithSearch(): void + { + $uniqueId = \uniqid(); + $repositoryName = 'test-search-unique-' . $uniqueId; + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $result = []; + $this->assertEventually(function () use (&$result, $uniqueId, $repositoryName) { + $result = $this->vcsAdapter->searchRepositories(static::$owner, 1, 10, $uniqueId); + $this->assertContains($repositoryName, array_column($result['items'], 'name')); + }, 30000, 2000); + + $this->assertArrayHasKey('items', $result); + $this->assertNotEmpty($result['items']); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + /** + * Bitbucket rejects a build status with no URL, and reports 'pending' as INPROGRESS. + */ + public function testUpdateCommitStatusDefaultsUrlToCommit(): void + { + $repositoryName = 'test-update-commit-status-url-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; + + $this->vcsAdapter->updateCommitStatus( + $repositoryName, + $commitHash, + static::$owner, + 'pending', + 'Build started', + '', + 'ci/test' + ); + + $written = null; + foreach ($this->vcsAdapter->getCommitStatuses(static::$owner, $repositoryName, $commitHash) as $status) { + if ($status['context'] === 'ci/test') { + $written = $status; + } + } + + $this->assertNotNull($written, 'No status reported under the context it was written with'); + $this->assertSame('pending', $written['state']); + $this->assertSame( + $this->vcsAdapter->getCommitUrl(static::$owner, $repositoryName, $commitHash), + $written['target_url'] + ); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGetCommitStatusesEmptyForNewCommit(): void + { + $repositoryName = 'test-get-commit-statuses-empty-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; + + $result = $this->vcsAdapter->getCommitStatuses(static::$owner, $repositoryName, $commitHash); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testGenerateCloneCommandWithTag(): void + { + $repositoryName = 'test-clone-tag-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $directory = '/tmp/test-clone-tag-' . \uniqid(); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; + + $this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v1.0.0', $commitHash); + + $command = $this->vcsAdapter->generateCloneCommand( + static::$owner, + $repositoryName, + 'v1.0.0', + Git::CLONE_TYPE_TAG, + $directory, + '/' + ); + + $this->assertStringContainsString('refs/tags', $command); + $this->assertStringContainsString('v1.0.0', $command); + + $output = []; + \exec($command . ' 2>&1', $output, $exitCode); + $this->assertSame(0, $exitCode, implode("\n", $output)); + $this->assertFileExists($directory . '/README.md'); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + if (\is_dir($directory)) { + \exec('rm -rf ' . escapeshellarg($directory)); + } + } + } + + /** + * Bitbucket signs no archive URLs, so the adapter leaves the opt-in + * presigned URL support unimplemented. + */ + public function testGetRepositoryPresignedUrlIsUnsupported(): void + { + $this->expectException(\Exception::class); + $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, 'some-repo', static::$defaultBranch); + } + + /** + * Bitbucket Cloud can only deliver webhooks to publicly reachable URLs, so + * unlike the self-hosted adapters this only covers the API side of the + * subscription, not a real delivery. + */ + public function testCreateWebhook(): void + { + $repositoryName = 'test-create-webhook-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + /** @var Bitbucket $adapter */ + $adapter = $this->vcsAdapter; + + $uuid = $adapter->createRepositoryWebhook( + static::$owner, + $repositoryName, + 'https://example.com/webhook', + 'secret-token', + ['push', 'pull_request'] + ); + + $this->assertIsString($uuid); + $this->assertNotEmpty($uuid); + + $this->assertTrue($adapter->deleteWebhook(static::$owner, $repositoryName, $uuid)); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + public function testValidateWebhookEvent(): void + { + $payload = '{"push":{"changes":[]}}'; + $secret = 'my-webhook-secret'; + $signature = 'sha256=' . hash_hmac('sha256', $payload, $secret); + + $this->assertTrue($this->vcsAdapter->validateWebhookEvent($payload, $signature, $secret)); + + // Unprefixed digests and plain secrets are both rejected + $this->assertFalse($this->vcsAdapter->validateWebhookEvent($payload, hash_hmac('sha256', $payload, $secret), $secret)); + $this->assertFalse($this->vcsAdapter->validateWebhookEvent($payload, $secret, $secret)); + $this->assertFalse($this->vcsAdapter->validateWebhookEvent($payload, 'sha256=wrongsig', $secret)); + } + + public function testGetEventPush(): void + { + $result = $this->vcsAdapter->getEvent('repo:push', $this->pushPayload()); + + $this->assertIsArray($result); + $this->assertFalse($result['branchCreated']); + $this->assertFalse($result['branchDeleted']); + $this->assertSame('main', $result['branch']); + $this->assertSame('https://bitbucket.org/test-workspace/test-repo/branch/main', $result['branchUrl']); + $this->assertSame('{11111111-2222-3333-4444-555555555555}', $result['repositoryId']); + $this->assertSame('test-repo', $result['repositoryName']); + $this->assertSame('https://bitbucket.org/test-workspace/test-repo', $result['repositoryUrl']); + $this->assertSame('test-workspace', $result['owner']); + $this->assertSame('abc123', $result['commitHash']); + $this->assertSame('Test User', $result['headCommitAuthorName']); + $this->assertSame('test@example.com', $result['headCommitAuthorEmail']); + $this->assertSame('Test commit', $result['headCommitMessage']); + $this->assertSame('https://bitbucket.org/test-workspace/test-repo/commits/abc123', $result['headCommitUrl']); + $this->assertSame('https://bitbucket.org/tester', $result['authorUrl']); + $this->assertSame('https://bitbucket.org/account/tester/avatar/', $result['authorAvatarUrl']); + $this->assertFalse($result['external']); + // Bitbucket's push payload carries no file lists + $this->assertSame([], $result['affectedFiles']); + } + + /** + * Bitbucket only names the author in a raw "Name " string when the + * commit isn't linked to an account. + */ + public function testGetEventPushWithLinkedAuthor(): void + { + $payload = $this->pushPayload(author: [ + 'raw' => 'Test User ', + 'user' => ['display_name' => 'Linked User'], + ]); + + $result = $this->vcsAdapter->getEvent('repo:push', $payload); + + $this->assertSame('Linked User', $result['headCommitAuthorName']); + $this->assertSame('test@example.com', $result['headCommitAuthorEmail']); + } + + public function testGetEventPushDetectsBranchCreated(): void + { + $result = $this->vcsAdapter->getEvent('repo:push', $this->pushPayload(created: true)); + + $this->assertTrue($result['branchCreated']); + $this->assertFalse($result['branchDeleted']); + $this->assertSame('main', $result['branch']); + } + + public function testGetEventPushDetectsBranchDeleted(): void + { + // A deleted branch is reported with no new state, only the old one + $payload = json_encode([ + 'actor' => ['links' => []], + 'repository' => [ + 'uuid' => '{11111111-2222-3333-4444-555555555555}', + 'name' => 'test-repo', + 'full_name' => 'test-workspace/test-repo', + 'workspace' => ['slug' => 'test-workspace'], + 'links' => ['html' => ['href' => 'https://bitbucket.org/test-workspace/test-repo']], + ], + 'push' => [ + 'changes' => [ + [ + 'new' => null, + 'old' => ['type' => 'branch', 'name' => 'feature', 'target' => ['hash' => 'abc123']], + 'created' => false, + 'closed' => true, + ], + ], + ], + ]); + + if ($payload === false) { + $this->fail('Failed to encode JSON payload'); + } + + $result = $this->vcsAdapter->getEvent('repo:push', $payload); + + $this->assertFalse($result['branchCreated']); + $this->assertTrue($result['branchDeleted']); + $this->assertSame('feature', $result['branch']); + $this->assertSame('', $result['commitHash']); + } + + public function testGetEventPullRequest(): void + { + $result = $this->vcsAdapter->getEvent('pullrequest:created', $this->pullRequestPayload()); + + $this->assertIsArray($result); + $this->assertSame('feature', $result['branch']); + $this->assertSame('https://bitbucket.org/test-workspace/test-repo/branch/feature', $result['branchUrl']); + $this->assertSame('opened', $result['action']); + $this->assertFalse($result['external']); + $this->assertSame(1, $result['pullRequestNumber']); + $this->assertSame('{11111111-2222-3333-4444-555555555555}', $result['repositoryId']); + $this->assertSame('test-repo', $result['repositoryName']); + $this->assertSame('abc123', $result['commitHash']); + $this->assertSame('https://bitbucket.org/test-workspace/test-repo/commits/abc123', $result['headCommitUrl']); + } + + public function testGetEventPullRequestActionMapping(): void + { + $mapping = [ + 'pullrequest:created' => 'opened', + 'pullrequest:updated' => 'synchronize', + 'pullrequest:fulfilled' => 'closed', + 'pullrequest:rejected' => 'closed', + ]; + + foreach ($mapping as $event => $action) { + $result = $this->vcsAdapter->getEvent($event, $this->pullRequestPayload()); + + $this->assertSame($action, $result['action'], "event '{$event}' should map to '{$action}'"); + } + } + + public function testGetEventPullRequestDetectsExternal(): void + { + $result = $this->vcsAdapter->getEvent( + 'pullrequest:created', + $this->pullRequestPayload(sourceRepositoryId: '{99999999-2222-3333-4444-555555555555}') + ); + + $this->assertTrue($result['external']); + } + + /** + * @param array|null $author + */ + private function pushPayload(?array $author = null, bool $created = false): string + { + $payload = json_encode([ + 'actor' => [ + 'display_name' => 'Tester', + 'links' => [ + 'html' => ['href' => 'https://bitbucket.org/tester'], + 'avatar' => ['href' => 'https://bitbucket.org/account/tester/avatar/'], + ], + ], + 'repository' => [ + 'uuid' => '{11111111-2222-3333-4444-555555555555}', + 'name' => 'test-repo', + 'full_name' => 'test-workspace/test-repo', + 'workspace' => ['slug' => 'test-workspace'], + 'links' => ['html' => ['href' => 'https://bitbucket.org/test-workspace/test-repo']], + ], + 'push' => [ + 'changes' => [ + [ + 'created' => $created, + 'closed' => false, + 'old' => $created ? null : ['type' => 'branch', 'name' => 'main'], + 'new' => [ + 'type' => 'branch', + 'name' => 'main', + 'target' => [ + 'hash' => 'abc123', + 'message' => 'Test commit', + 'author' => $author ?? ['raw' => 'Test User '], + 'links' => ['html' => ['href' => 'https://bitbucket.org/test-workspace/test-repo/commits/abc123']], + ], + ], + ], + ], + ], + ]); + + if ($payload === false) { + $this->fail('Failed to encode JSON payload'); + } + + return $payload; + } + + private function pullRequestPayload(string $sourceRepositoryId = '{11111111-2222-3333-4444-555555555555}'): string + { + $payload = json_encode([ + 'actor' => [ + 'links' => [ + 'html' => ['href' => 'https://bitbucket.org/tester'], + 'avatar' => ['href' => 'https://bitbucket.org/account/tester/avatar/'], + ], + ], + 'repository' => [ + 'uuid' => '{11111111-2222-3333-4444-555555555555}', + 'name' => 'test-repo', + 'full_name' => 'test-workspace/test-repo', + 'workspace' => ['slug' => 'test-workspace'], + 'links' => ['html' => ['href' => 'https://bitbucket.org/test-workspace/test-repo']], + ], + 'pullrequest' => [ + 'id' => 1, + 'title' => 'Test PR', + 'state' => 'OPEN', + 'source' => [ + 'branch' => ['name' => 'feature'], + 'commit' => ['hash' => 'abc123'], + 'repository' => ['uuid' => $sourceRepositoryId], + ], + 'destination' => [ + 'branch' => ['name' => 'main'], + 'repository' => ['uuid' => '{11111111-2222-3333-4444-555555555555}'], + ], + ], + ]); + + if ($payload === false) { + $this->fail('Failed to encode JSON payload'); + } + + return $payload; + } +} From 5690b9e74f790cc82f4c17676343b6d739d09152 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Wed, 29 Jul 2026 12:57:08 +0530 Subject: [PATCH 02/34] Fix Bitbucket webhook ids and multi-ref pushes --- src/VCS/Adapter/Git/Bitbucket.php | 267 ++++++++++++++++++---------- tests/VCS/Adapter/BitbucketTest.php | 106 ++++++++++- 2 files changed, 278 insertions(+), 95 deletions(-) diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 2a6893de..ddbd1772 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -980,15 +980,14 @@ public function updateComment(string $owner, string $repositoryName, string $com } /** - * Bitbucket identifies webhooks by UUID, which has no integer form, so this - * reports 0 on success. Use createRepositoryWebhook() to get the UUID - * needed to address the hook later. + * Bitbucket identifies webhooks by UUID, which an int return can't carry. + * Rather than leave a live hook behind under an id the caller can never + * address it by, this creates nothing: use createRepositoryWebhook(), which + * returns the UUID that deleteWebhook() takes. */ public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int { - $this->createRepositoryWebhook($owner, $repositoryName, $url, $secret, $events); - - return 0; + throw new Exception('createWebhook() is not supported for ' . $this->getName() . '; its webhooks are identified by UUID -- use createRepositoryWebhook() instead'); } /** @@ -1217,6 +1216,18 @@ public function generateCloneCommand(string $owner, string $repositoryName, stri } public function getEvent(string $event, string $payload): array + { + return $this->getEvents($event, $payload)[0] ?? []; + } + + /** + * Bitbucket batches every ref a push touched into a single delivery, so one + * payload can describe several branches, of which getEvent() only reports + * the first. This reports all of them, in the order the payload lists them. + * + * @return array> + */ + public function getEvents(string $event, string $payload): array { $payloadArray = json_decode($payload, true); if ($payloadArray === null || !is_array($payloadArray)) { @@ -1225,113 +1236,183 @@ public function getEvent(string $event, string $payload): array $repository = is_array($payloadArray['repository'] ?? null) ? $payloadArray['repository'] : []; $actor = is_array($payloadArray['actor'] ?? null) ? $payloadArray['actor'] : []; - $actorLinks = is_array($actor['links'] ?? null) ? $actor['links'] : []; - - $repositoryId = strval($repository['uuid'] ?? ''); - $repositoryName = $repository['name'] ?? ''; - $repositoryUrl = $repository['links']['html']['href'] ?? ''; - $workspace = is_array($repository['workspace'] ?? null) ? $repository['workspace'] : []; - $owner = $workspace['slug'] ?? ''; - if (empty($owner) && strpos((string) ($repository['full_name'] ?? ''), '/') !== false) { - $owner = explode('/', (string) $repository['full_name'])[0]; - } switch ($event) { case 'repo:push': $push = is_array($payloadArray['push'] ?? null) ? $payloadArray['push'] : []; $changes = is_array($push['changes'] ?? null) ? $push['changes'] : []; - // A push that advances several refs at once is reported as one - // event with one change per ref. The shared event shape only - // carries a single branch, so report the first change, matching - // how the other adapters report a single ref per push. - $change = is_array($changes[0] ?? null) ? $changes[0] : []; - - $new = is_array($change['new'] ?? null) ? $change['new'] : []; - $old = is_array($change['old'] ?? null) ? $change['old'] : []; - - // A deleted branch is reported as a change with no new state - $branch = $new['name'] ?? ($old['name'] ?? ''); - $target = is_array($new['target'] ?? null) ? $new['target'] : []; - $author = is_array($target['author'] ?? null) ? $target['author'] : []; - $raw = (string) ($author['raw'] ?? ''); - - $authorName = $author['user']['display_name'] ?? ''; - if (empty($authorName)) { - $authorName = \trim(\preg_replace('/<[^>]*>/', '', $raw) ?? ''); - } + $events = []; + foreach ($changes as $change) { + if (!is_array($change) || !$this->isBranchChange($change)) { + continue; + } - $authorEmail = ''; - if (\preg_match('/<([^>]*)>/', $raw, $matches) === 1) { - $authorEmail = $matches[1]; + $events[] = $this->parsePushChange($change, $repository, $actor); } - return [ - 'branchCreated' => ($change['created'] ?? false) === true, - 'branchDeleted' => ($change['closed'] ?? false) === true, - 'branch' => $branch, - 'branchUrl' => !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . '/branch/' . $branch : '', - 'repositoryId' => $repositoryId, - 'repositoryName' => $repositoryName, - 'repositoryUrl' => $repositoryUrl, - 'installationId' => '', // Bitbucket has no installations - 'commitHash' => $target['hash'] ?? '', - 'owner' => $owner, - 'authorUrl' => $actorLinks['html']['href'] ?? '', - 'authorAvatarUrl' => $actorLinks['avatar']['href'] ?? '', - 'headCommitAuthorName' => $authorName, - 'headCommitAuthorEmail' => $authorEmail, - 'headCommitMessage' => $target['message'] ?? '', - 'headCommitUrl' => $target['links']['html']['href'] ?? '', - 'external' => false, - 'pullRequestNumber' => '', - 'action' => '', - // Bitbucket's push payload carries no per-commit file lists - 'affectedFiles' => [], - ]; + return $events; case 'pullrequest:created': case 'pullrequest:updated': case 'pullrequest:fulfilled': case 'pullrequest:rejected': - $pullRequest = is_array($payloadArray['pullrequest'] ?? null) ? $payloadArray['pullrequest'] : []; - $source = is_array($pullRequest['source'] ?? null) ? $pullRequest['source'] : []; - $destination = is_array($pullRequest['destination'] ?? null) ? $pullRequest['destination'] : []; - - $branch = $source['branch']['name'] ?? ''; - $commitHash = $source['commit']['hash'] ?? ''; - - // A pull request whose source lives in another repository is a - // fork-based external contribution; defaults to false - // (intentional) if either uuid is missing. - $sourceRepositoryId = $source['repository']['uuid'] ?? null; - $destinationRepositoryId = $destination['repository']['uuid'] ?? null; - $external = $sourceRepositoryId !== null - && $destinationRepositoryId !== null - && $sourceRepositoryId !== $destinationRepositoryId; - - return [ - 'branch' => $branch, - 'branchUrl' => !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . '/branch/' . $branch : '', - 'repositoryId' => $repositoryId, - 'repositoryName' => $repositoryName, - 'repositoryUrl' => $repositoryUrl, - 'installationId' => '', - 'commitHash' => $commitHash, - 'owner' => $owner, - 'authorUrl' => $actorLinks['html']['href'] ?? '', - 'authorAvatarUrl' => $actorLinks['avatar']['href'] ?? '', - 'headCommitUrl' => !empty($repositoryUrl) && !empty($commitHash) ? $repositoryUrl . '/commits/' . $commitHash : '', - 'external' => $external, - 'pullRequestNumber' => $pullRequest['id'] ?? '', - 'action' => self::PULL_REQUEST_ACTION_MAP[$event], - ]; + return [$this->parsePullRequestEvent($event, $payloadArray, $repository, $actor)]; default: return []; } } + /** + * A push carries tag changes alongside branch ones, and the shared event + * shape describes a branch push, so tags are left out rather than reported + * as a branch. A change with no type at all is taken to be a branch. + * + * @param array $change + */ + private function isBranchChange(array $change): bool + { + $new = is_array($change['new'] ?? null) ? $change['new'] : []; + $old = is_array($change['old'] ?? null) ? $change['old'] : []; + + $type = $new['type'] ?? ($old['type'] ?? 'branch'); + + return \in_array($type, ['branch', 'named_branch'], true); + } + + /** + * Identifier of the repository a webhook payload describes. Bitbucket's + * repository UUID isn't routable on its own, so this reports the + * "workspace/slug" pair getRepositoryName() resolves, matching the `id` + * createRepository() and getRepository() report. + * + * @param array $repository + */ + private function getEventRepositoryId(array $repository): string + { + return strval($repository['full_name'] ?? ''); + } + + /** + * @param array $repository + * @return array{owner: string, url: string} + */ + private function getEventRepositoryOwner(array $repository): array + { + $url = (string) ($repository['links']['html']['href'] ?? ''); + + $workspace = is_array($repository['workspace'] ?? null) ? $repository['workspace'] : []; + $owner = (string) ($workspace['slug'] ?? ''); + + $fullName = (string) ($repository['full_name'] ?? ''); + if (empty($owner) && strpos($fullName, '/') !== false) { + $owner = explode('/', $fullName)[0]; + } + + return ['owner' => $owner, 'url' => $url]; + } + + /** + * @param array $change + * @param array $repository + * @param array $actor + * @return array + */ + private function parsePushChange(array $change, array $repository, array $actor): array + { + $actorLinks = is_array($actor['links'] ?? null) ? $actor['links'] : []; + ['owner' => $owner, 'url' => $repositoryUrl] = $this->getEventRepositoryOwner($repository); + + $new = is_array($change['new'] ?? null) ? $change['new'] : []; + $old = is_array($change['old'] ?? null) ? $change['old'] : []; + + // A deleted branch is reported as a change with no new state + $branch = $new['name'] ?? ($old['name'] ?? ''); + $target = is_array($new['target'] ?? null) ? $new['target'] : []; + $author = is_array($target['author'] ?? null) ? $target['author'] : []; + $raw = (string) ($author['raw'] ?? ''); + + $authorName = $author['user']['display_name'] ?? ''; + if (empty($authorName)) { + $authorName = \trim(\preg_replace('/<[^>]*>/', '', $raw) ?? ''); + } + + $authorEmail = ''; + if (\preg_match('/<([^>]*)>/', $raw, $matches) === 1) { + $authorEmail = $matches[1]; + } + + return [ + 'branchCreated' => ($change['created'] ?? false) === true, + 'branchDeleted' => ($change['closed'] ?? false) === true, + 'branch' => $branch, + 'branchUrl' => !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . '/branch/' . $branch : '', + 'repositoryId' => $this->getEventRepositoryId($repository), + 'repositoryName' => $repository['name'] ?? '', + 'repositoryUrl' => $repositoryUrl, + 'installationId' => '', // Bitbucket has no installations + 'commitHash' => $target['hash'] ?? '', + 'owner' => $owner, + 'authorUrl' => $actorLinks['html']['href'] ?? '', + 'authorAvatarUrl' => $actorLinks['avatar']['href'] ?? '', + 'headCommitAuthorName' => $authorName, + 'headCommitAuthorEmail' => $authorEmail, + 'headCommitMessage' => $target['message'] ?? '', + 'headCommitUrl' => $target['links']['html']['href'] ?? '', + 'external' => false, + 'pullRequestNumber' => '', + 'action' => '', + // Bitbucket's push payload carries no per-commit file lists + 'affectedFiles' => [], + ]; + } + + /** + * @param array $payloadArray + * @param array $repository + * @param array $actor + * @return array + */ + private function parsePullRequestEvent(string $event, array $payloadArray, array $repository, array $actor): array + { + $actorLinks = is_array($actor['links'] ?? null) ? $actor['links'] : []; + ['owner' => $owner, 'url' => $repositoryUrl] = $this->getEventRepositoryOwner($repository); + + $pullRequest = is_array($payloadArray['pullrequest'] ?? null) ? $payloadArray['pullrequest'] : []; + $source = is_array($pullRequest['source'] ?? null) ? $pullRequest['source'] : []; + $destination = is_array($pullRequest['destination'] ?? null) ? $pullRequest['destination'] : []; + + $branch = $source['branch']['name'] ?? ''; + $commitHash = $source['commit']['hash'] ?? ''; + + // A pull request whose source lives in another repository is a + // fork-based external contribution; defaults to false (intentional) if + // either uuid is missing. + $sourceRepositoryId = $source['repository']['uuid'] ?? null; + $destinationRepositoryId = $destination['repository']['uuid'] ?? null; + $external = $sourceRepositoryId !== null + && $destinationRepositoryId !== null + && $sourceRepositoryId !== $destinationRepositoryId; + + return [ + 'branch' => $branch, + 'branchUrl' => !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . '/branch/' . $branch : '', + 'repositoryId' => $this->getEventRepositoryId($repository), + 'repositoryName' => $repository['name'] ?? '', + 'repositoryUrl' => $repositoryUrl, + 'installationId' => '', + 'commitHash' => $commitHash, + 'owner' => $owner, + 'authorUrl' => $actorLinks['html']['href'] ?? '', + 'authorAvatarUrl' => $actorLinks['avatar']['href'] ?? '', + 'headCommitUrl' => !empty($repositoryUrl) && !empty($commitHash) ? $repositoryUrl . '/commits/' . $commitHash : '', + 'external' => $external, + 'pullRequestNumber' => $pullRequest['id'] ?? '', + 'action' => self::PULL_REQUEST_ACTION_MAP[$event], + ]; + } + /** * Bitbucket prefixes the digest with the algorithm it used, as GitHub does. */ diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 89470b5f..4d512645 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -289,7 +289,8 @@ public function testGetEventPush(): void $this->assertFalse($result['branchDeleted']); $this->assertSame('main', $result['branch']); $this->assertSame('https://bitbucket.org/test-workspace/test-repo/branch/main', $result['branchUrl']); - $this->assertSame('{11111111-2222-3333-4444-555555555555}', $result['repositoryId']); + // The routable identifier, matching what getRepositoryName() resolves + $this->assertSame('test-workspace/test-repo', $result['repositoryId']); $this->assertSame('test-repo', $result['repositoryName']); $this->assertSame('https://bitbucket.org/test-workspace/test-repo', $result['repositoryUrl']); $this->assertSame('test-workspace', $result['owner']); @@ -367,6 +368,106 @@ public function testGetEventPushDetectsBranchDeleted(): void $this->assertSame('', $result['commitHash']); } + /** + * A repositoryId has to be resolvable by the adapter that reported it. + */ + public function testGetEventReportsResolvableRepositoryId(): void + { + $repositoryName = 'test-event-repository-id-' . \uniqid(); + $created = $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $payload = json_encode([ + 'repository' => [ + 'uuid' => $created['uuid'] ?? '', + 'name' => $repositoryName, + 'full_name' => $created['full_name'] ?? '', + 'workspace' => ['slug' => $this->ownerPath()], + ], + 'push' => ['changes' => [['new' => ['type' => 'branch', 'name' => 'main']]]], + ]); + + if ($payload === false) { + $this->fail('Failed to encode JSON payload'); + } + + $event = $this->vcsAdapter->getEvent('repo:push', $payload); + + $this->assertSame($created['id'], $event['repositoryId']); + $this->assertSame($repositoryName, $this->vcsAdapter->getRepositoryName($event['repositoryId'])); + } finally { + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + } + } + + /** + * Bitbucket reports every ref a push touched in one delivery. + */ + public function testGetEventsReportsEveryPushedBranch(): void + { + $payload = json_encode([ + 'actor' => ['links' => []], + 'repository' => [ + 'name' => 'test-repo', + 'full_name' => 'test-workspace/test-repo', + 'workspace' => ['slug' => 'test-workspace'], + 'links' => ['html' => ['href' => 'https://bitbucket.org/test-workspace/test-repo']], + ], + 'push' => [ + 'changes' => [ + ['new' => ['type' => 'branch', 'name' => 'main', 'target' => ['hash' => 'aaa111']], 'created' => false, 'closed' => false], + ['new' => ['type' => 'tag', 'name' => 'v1.0.0', 'target' => ['hash' => 'bbb222']]], + ['new' => ['type' => 'branch', 'name' => 'feature', 'target' => ['hash' => 'ccc333']], 'created' => true, 'closed' => false], + ], + ], + ]); + + if ($payload === false) { + $this->fail('Failed to encode JSON payload'); + } + + /** @var Bitbucket $adapter */ + $adapter = $this->vcsAdapter; + + $events = $adapter->getEvents('repo:push', $payload); + + // The tag is left out; the shared event shape describes a branch push + $this->assertCount(2, $events); + $this->assertSame(['main', 'feature'], array_column($events, 'branch')); + $this->assertSame(['aaa111', 'ccc333'], array_column($events, 'commitHash')); + $this->assertTrue($events[1]['branchCreated']); + + // getEvent() reports the first of them + $this->assertSame($events[0], $adapter->getEvent('repo:push', $payload)); + } + + /** + * A tag-only push has no branch to report. + */ + public function testGetEventTagPushIsNotReportedAsBranch(): void + { + $payload = json_encode([ + 'repository' => ['name' => 'test-repo', 'full_name' => 'test-workspace/test-repo'], + 'push' => ['changes' => [['new' => ['type' => 'tag', 'name' => 'v1.0.0', 'target' => ['hash' => 'aaa111']]]]], + ]); + + if ($payload === false) { + $this->fail('Failed to encode JSON payload'); + } + + $this->assertSame([], $this->vcsAdapter->getEvent('repo:push', $payload)); + } + + /** + * The interface's int return can't carry Bitbucket's UUID, so no hook is + * created through it at all. + */ + public function testCreateWebhookThroughSharedInterfaceIsRefused(): void + { + $this->expectException(\Exception::class); + $this->vcsAdapter->createWebhook(static::$owner, 'some-repo', 'https://example.com/webhook', 'secret'); + } + public function testGetEventPullRequest(): void { $result = $this->vcsAdapter->getEvent('pullrequest:created', $this->pullRequestPayload()); @@ -377,7 +478,8 @@ public function testGetEventPullRequest(): void $this->assertSame('opened', $result['action']); $this->assertFalse($result['external']); $this->assertSame(1, $result['pullRequestNumber']); - $this->assertSame('{11111111-2222-3333-4444-555555555555}', $result['repositoryId']); + // The routable identifier, matching what getRepositoryName() resolves + $this->assertSame('test-workspace/test-repo', $result['repositoryId']); $this->assertSame('test-repo', $result['repositoryName']); $this->assertSame('abc123', $result['commitHash']); $this->assertSame('https://bitbucket.org/test-workspace/test-repo/commits/abc123', $result['headCommitUrl']); From ca2c1fa9c2988d1ff68f1d07240849d0bc7c9008 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 16:43:24 +0530 Subject: [PATCH 03/34] Return Bitbucket's webhook UUID from createWebhook Bitbucket identifies a hook by UUID, which an int return cannot carry, so createWebhook() threw and a separate createRepositoryWebhook() returned the UUID. Widening the return to int|string lets Bitbucket implement the same method every other adapter does. --- src/VCS/Adapter/Git.php | 6 ++++-- src/VCS/Adapter/Git/Bitbucket.php | 17 ++++------------- tests/VCS/Adapter/BitbucketTest.php | 12 +----------- 3 files changed, 9 insertions(+), 26 deletions(-) diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index 8769b94b..f0ba9bff 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -80,9 +80,11 @@ abstract public function createPullRequest(string $owner, string $repositoryName * @param string $url Webhook URL to send events to * @param string $secret Webhook secret for signature validation * @param array $events Events to trigger the webhook - * @return int Webhook ID + * @return int|string Webhook ID, as the provider identifies it: an int on + * the providers that number their hooks, a string where + * they don't (Bitbucket identifies them by UUID) */ - abstract public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int; + abstract public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int|string; /** diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index ddbd1772..50f65b9d 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -980,24 +980,15 @@ public function updateComment(string $owner, string $repositoryName, string $com } /** - * Bitbucket identifies webhooks by UUID, which an int return can't carry. - * Rather than leave a live hook behind under an id the caller can never - * address it by, this creates nothing: use createRepositoryWebhook(), which - * returns the UUID that deleteWebhook() takes. - */ - public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int - { - throw new Exception('createWebhook() is not supported for ' . $this->getName() . '; its webhooks are identified by UUID -- use createRepositoryWebhook() instead'); - } - - /** - * Create a webhook on a repository and return Bitbucket's UUID for it. + * Create a webhook on a repository. Bitbucket identifies hooks by UUID + * rather than by number, so this returns the UUID that deleteWebhook() + * takes. * * @param array $events Event names, either this library's ('push', * 'pull_request') or Bitbucket's own keys * (e.g. 'repo:push') */ - public function createRepositoryWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): string + public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): string { $apiUrl = "/repositories/{$owner}/{$repositoryName}/hooks"; diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 4d512645..597caff0 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -249,7 +249,7 @@ public function testCreateWebhook(): void /** @var Bitbucket $adapter */ $adapter = $this->vcsAdapter; - $uuid = $adapter->createRepositoryWebhook( + $uuid = $adapter->createWebhook( static::$owner, $repositoryName, 'https://example.com/webhook', @@ -458,16 +458,6 @@ public function testGetEventTagPushIsNotReportedAsBranch(): void $this->assertSame([], $this->vcsAdapter->getEvent('repo:push', $payload)); } - /** - * The interface's int return can't carry Bitbucket's UUID, so no hook is - * created through it at all. - */ - public function testCreateWebhookThroughSharedInterfaceIsRefused(): void - { - $this->expectException(\Exception::class); - $this->vcsAdapter->createWebhook(static::$owner, 'some-repo', 'https://example.com/webhook', 'secret'); - } - public function testGetEventPullRequest(): void { $result = $this->vcsAdapter->getEvent('pullrequest:created', $this->pullRequestPayload()); From fe8e11592b906635b03052035e6761d17c346f97 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 16:56:44 +0530 Subject: [PATCH 04/34] Move the Bitbucket tests onto the shared Base contract Bitbucket declares the parts of the contract it does not offer - presigned archive urls, check runs, namespace listing, computed languages, user lookup by handle, webhook delivery to the catcher, and file lists in a push event - and keeps only the tests describing itself: workspaces, uuid webhooks and user lookups, multi-ref pushes, and its action mapping. The event payload builders replace hand-written getEvent tests, so the shared assertions cover them, and Base's repository id is now overridable because Bitbucket routes on "workspace/slug" rather than a numeric id. getEvent() returned an empty event for a malformed payload rather than throwing, as the other adapters do. --- src/VCS/Adapter/Git/Bitbucket.php | 18 +- tests/VCS/Adapter/BitbucketTest.php | 584 +++++++++++----------------- tests/VCS/Base.php | 85 ++-- 3 files changed, 287 insertions(+), 400 deletions(-) diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 50f65b9d..4a3f6782 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -170,20 +170,6 @@ protected function generateAccessToken(string $privateKey, string $appId): void return; } - /** - * Bitbucket paths are literal URL segments, so unlike GitHub it never - * resolves './' or '.' to the repository root on its own. - */ - private function normalizeRepositoryPath(string $path): string - { - $segments = array_filter( - explode('/', $path), - fn (string $segment): bool => $segment !== '' && $segment !== '.' - ); - - return implode('/', $segments); - } - /** * Encode a repository path for use in a URL while keeping its separators. */ @@ -1221,8 +1207,8 @@ public function getEvent(string $event, string $payload): array public function getEvents(string $event, string $payload): array { $payloadArray = json_decode($payload, true); - if ($payloadArray === null || !is_array($payloadArray)) { - return []; + if (!is_array($payloadArray)) { + throw new Exception("Invalid payload."); } $repository = is_array($payloadArray['repository'] ?? null) ? $payloadArray['repository'] : []; diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 597caff0..4e511ca9 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -6,14 +6,69 @@ use Utopia\Cache\Cache; use Utopia\System\System; use Utopia\Tests\Base; -use Utopia\VCS\Adapter\Git; use Utopia\VCS\Adapter\Git\Bitbucket; class BitbucketTest extends Base { + /** + * Bitbucket has no repository ids, so events report the "workspace/slug" + * pair its API routes on. + */ + protected const EVENT_REPOSITORY_ID = self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME; + + private const REPOSITORY_URL = 'https://bitbucket.org/' . self::EVENT_REPOSITORY_ID; + + private const REPOSITORY_UUID = '{11111111-2222-3333-4444-555555555555}'; + + private const FORK_UUID = '{99999999-2222-3333-4444-555555555555}'; + protected static string $accessToken = ''; protected static string $owner = ''; protected static string $defaultBranch = 'main'; + protected static string $eventHeader = 'x-event-key'; + protected static string $signatureHeader = 'x-hub-signature'; + protected static string $pushEventName = 'repo:push'; + protected static string $pullRequestEventName = 'pullrequest:created'; + + /** + * Bitbucket has no app installations, and no repository to resolve an owner + * from either - getOwnerName() reports the account the token belongs to. + */ + protected static bool $supportsInstallationRepository = false; + protected static bool $resolvesOwnerFromRepositoryId = false; + + /** + * Bitbucket signs no archive urls, runs no checks, groups repositories in + * workspaces rather than namespaces (see testListWorkspaces below), and has + * a repository's language set by hand instead of computing it. + */ + protected static bool $supportsPresignedUrls = false; + protected static bool $supportsCheckRuns = false; + protected static bool $supportsNamespaceListing = false; + protected static bool $supportsRepositoryLanguages = false; + + /** + * Bitbucket looks accounts up by uuid rather than by handle, so the shared + * lookup does not apply; testGetUser below covers it instead. + */ + protected static bool $supportsUserLookup = false; + + /** + * Bitbucket Cloud only delivers webhooks to publicly reachable urls, so it + * cannot reach the test catcher. testCreateWebhook below covers the API side + * of a subscription. + */ + protected static bool $supportsWebhookDelivery = false; + + /** + * Bitbucket's push payload carries no per-commit file lists. + */ + protected static bool $reportsAffectedFilesInPushEvent = false; + + protected function signWebhookPayload(string $payload, string $secret): string + { + return 'sha256=' . hash_hmac('sha256', $payload, $secret); + } protected function setupAdapter(): void { @@ -47,27 +102,127 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } - public function testWebhookHeaderNames(): void + /** + * Bitbucket has no repository ids; createRepository() reports the + * "workspace/slug" pair its API routes on instead. + * + * @param array $repository + */ + protected function repositoryIdOf(array $repository): string + { + $this->assertArrayHasKey('id', $repository); + $this->assertIsString($repository['id']); + $this->assertStringContainsString('/', $repository['id']); + + return $repository['id']; + } + + /** + * Bitbucket reports a repository's owner as the workspace holding it. + * + * @param array $repository + */ + protected function ownerOf(array $repository): string + { + $this->assertArrayHasKey('workspace', $repository); + $this->assertIsArray($repository['workspace']); + $this->assertArrayHasKey('slug', $repository['workspace']); + + return (string) $repository['workspace']['slug']; + } + + protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string + { + $ref = [ + 'type' => 'branch', + 'name' => $branch, + 'target' => [ + 'hash' => static::EVENT_COMMIT_HASH, + 'message' => static::EVENT_COMMIT_MESSAGE, + 'author' => ['raw' => static::EVENT_AUTHOR_NAME . ' <' . static::EVENT_AUTHOR_EMAIL . '>'], + 'links' => ['html' => ['href' => self::REPOSITORY_URL . '/commits/' . static::EVENT_COMMIT_HASH]], + ], + ]; + + // A created branch has no old state and a deleted one no new state. The + // file lists go unused, Bitbucket naming no files in a push. + return (string) json_encode([ + 'actor' => $this->eventActor(), + 'repository' => $this->eventRepository(), + 'push' => [ + 'changes' => [[ + 'created' => $created, + 'closed' => $deleted, + 'old' => $created ? null : $ref, + 'new' => $deleted ? null : $ref, + ]], + ], + ]); + } + + protected function pullRequestPayload(bool $external = false): string + { + return (string) json_encode([ + 'actor' => $this->eventActor(), + 'repository' => $this->eventRepository(), + 'pullrequest' => [ + 'id' => static::EVENT_PULL_REQUEST_NUMBER, + 'title' => 'Test PR', + 'state' => 'OPEN', + 'source' => [ + 'branch' => ['name' => static::EVENT_HEAD_BRANCH], + 'commit' => ['hash' => static::EVENT_COMMIT_HASH], + // A source in another repository is a fork-based contribution + 'repository' => ['uuid' => $external ? self::FORK_UUID : self::REPOSITORY_UUID], + ], + 'destination' => [ + 'branch' => ['name' => static::$defaultBranch], + 'repository' => ['uuid' => self::REPOSITORY_UUID], + ], + ], + ]); + } + + /** + * @return array + */ + private function eventRepository(): array { - $this->assertSame('x-event-key', $this->vcsAdapter->getEventHeaderName()); - $this->assertSame('x-hub-signature', $this->vcsAdapter->getSignatureHeaderName()); + return [ + 'uuid' => self::REPOSITORY_UUID, + 'name' => static::EVENT_REPOSITORY_NAME, + 'full_name' => static::EVENT_REPOSITORY_ID, + 'workspace' => ['slug' => static::EVENT_OWNER], + 'links' => ['html' => ['href' => self::REPOSITORY_URL]], + ]; } /** - * Bitbucket has no numeric repository ids, so the owner always resolves from - * the account the token belongs to rather than from a repository. + * @return array */ - public function testGetOwnerName(): void + private function eventActor(): array { - $owner = $this->vcsAdapter->getOwnerName(''); + return [ + 'display_name' => 'Tester', + 'links' => [ + 'html' => ['href' => 'https://bitbucket.org/tester'], + 'avatar' => ['href' => 'https://bitbucket.org/account/tester/avatar/'], + ], + ]; + } - $this->assertIsString($owner); - $this->assertNotEmpty($owner); - $this->assertSame($owner, $this->vcsAdapter->getOwnerName('', 12345)); + /** + * Bitbucket reads the owner off the account its token belongs to, so no + * repository - not even one that does not exist - changes the answer. + */ + public function testGetOwnerNameIgnoresRepositoryId(): void + { + $this->assertSame($this->ownerPath(), $this->vcsAdapter->getOwnerName('', 999999999)); } /** - * Bitbucket looks accounts up by UUID rather than by handle. + * Bitbucket looks accounts up by uuid, and reports the handle as `nickname` + * for every account but the authenticated one. */ public function testGetUser(): void { @@ -81,16 +236,13 @@ public function testGetUser(): void $this->assertIsArray($result); $this->assertSame($me['uuid'], $result['id']); - // Bitbucket reports the handle as `nickname`, and `username` only for - // the authenticated account itself $this->assertSame($me['username'] ?? ($me['nickname'] ?? ''), $result['username']); } - public function testListRepositoryLanguages(): void - { - $this->markTestSkipped('Bitbucket does not compute language statistics; the language field is set by hand'); - } - + /** + * Workspaces are Bitbucket's grouping of repositories, in place of the + * namespaces the other providers list. + */ public function testListWorkspaces(): void { /** @var Bitbucket $adapter */ @@ -104,35 +256,14 @@ public function testListWorkspaces(): void $this->assertNotEmpty($result['items']); foreach ($result['items'] as $workspace) { - $this->assertArrayHasKey('id', $workspace); - $this->assertArrayHasKey('name', $workspace); $this->assertArrayHasKey('slug', $workspace); $this->assertNotEmpty($workspace['slug']); } } - public function testSearchRepositoriesWithSearch(): void - { - $uniqueId = \uniqid(); - $repositoryName = 'test-search-unique-' . $uniqueId; - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $result = []; - $this->assertEventually(function () use (&$result, $uniqueId, $repositoryName) { - $result = $this->vcsAdapter->searchRepositories(static::$owner, 1, 10, $uniqueId); - $this->assertContains($repositoryName, array_column($result['items'], 'name')); - }, 30000, 2000); - - $this->assertArrayHasKey('items', $result); - $this->assertNotEmpty($result['items']); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - /** - * Bitbucket rejects a build status with no URL, and reports 'pending' as INPROGRESS. + * Bitbucket rejects a build status with no url, so the adapter points one + * that was written without a url at the commit it describes. */ public function testUpdateCommitStatusDefaultsUrlToCommit(): void { @@ -167,68 +298,10 @@ public function testUpdateCommitStatusDefaultsUrlToCommit(): void $written['target_url'] ); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } - public function testGetCommitStatusesEmptyForNewCommit(): void - { - $repositoryName = 'test-get-commit-statuses-empty-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; - - $result = $this->vcsAdapter->getCommitStatuses(static::$owner, $repositoryName, $commitHash); - - $this->assertIsArray($result); - $this->assertEmpty($result); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } - } - - public function testGenerateCloneCommandWithTag(): void - { - $repositoryName = 'test-clone-tag-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - $directory = '/tmp/test-clone-tag-' . \uniqid(); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; - - $this->vcsAdapter->createTag(static::$owner, $repositoryName, 'v1.0.0', $commitHash); - - $command = $this->vcsAdapter->generateCloneCommand( - static::$owner, - $repositoryName, - 'v1.0.0', - Git::CLONE_TYPE_TAG, - $directory, - '/' - ); - - $this->assertStringContainsString('refs/tags', $command); - $this->assertStringContainsString('v1.0.0', $command); - - $output = []; - \exec($command . ' 2>&1', $output, $exitCode); - $this->assertSame(0, $exitCode, implode("\n", $output)); - $this->assertFileExists($directory . '/README.md'); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - if (\is_dir($directory)) { - \exec('rm -rf ' . escapeshellarg($directory)); - } - } - } - - /** - * Bitbucket signs no archive URLs, so the adapter leaves the opt-in - * presigned URL support unimplemented. - */ public function testGetRepositoryPresignedUrlIsUnsupported(): void { $this->expectException(\Exception::class); @@ -236,9 +309,7 @@ public function testGetRepositoryPresignedUrlIsUnsupported(): void } /** - * Bitbucket Cloud can only deliver webhooks to publicly reachable URLs, so - * unlike the self-hosted adapters this only covers the API side of the - * subscription, not a real delivery. + * Bitbucket identifies a webhook by uuid rather than by a numeric id. */ public function testCreateWebhook(): void { @@ -262,157 +333,51 @@ public function testCreateWebhook(): void $this->assertTrue($adapter->deleteWebhook(static::$owner, $repositoryName, $uuid)); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } - public function testValidateWebhookEvent(): void - { - $payload = '{"push":{"changes":[]}}'; - $secret = 'my-webhook-secret'; - $signature = 'sha256=' . hash_hmac('sha256', $payload, $secret); - - $this->assertTrue($this->vcsAdapter->validateWebhookEvent($payload, $signature, $secret)); - - // Unprefixed digests and plain secrets are both rejected - $this->assertFalse($this->vcsAdapter->validateWebhookEvent($payload, hash_hmac('sha256', $payload, $secret), $secret)); - $this->assertFalse($this->vcsAdapter->validateWebhookEvent($payload, $secret, $secret)); - $this->assertFalse($this->vcsAdapter->validateWebhookEvent($payload, 'sha256=wrongsig', $secret)); - } - - public function testGetEventPush(): void - { - $result = $this->vcsAdapter->getEvent('repo:push', $this->pushPayload()); - - $this->assertIsArray($result); - $this->assertFalse($result['branchCreated']); - $this->assertFalse($result['branchDeleted']); - $this->assertSame('main', $result['branch']); - $this->assertSame('https://bitbucket.org/test-workspace/test-repo/branch/main', $result['branchUrl']); - // The routable identifier, matching what getRepositoryName() resolves - $this->assertSame('test-workspace/test-repo', $result['repositoryId']); - $this->assertSame('test-repo', $result['repositoryName']); - $this->assertSame('https://bitbucket.org/test-workspace/test-repo', $result['repositoryUrl']); - $this->assertSame('test-workspace', $result['owner']); - $this->assertSame('abc123', $result['commitHash']); - $this->assertSame('Test User', $result['headCommitAuthorName']); - $this->assertSame('test@example.com', $result['headCommitAuthorEmail']); - $this->assertSame('Test commit', $result['headCommitMessage']); - $this->assertSame('https://bitbucket.org/test-workspace/test-repo/commits/abc123', $result['headCommitUrl']); - $this->assertSame('https://bitbucket.org/tester', $result['authorUrl']); - $this->assertSame('https://bitbucket.org/account/tester/avatar/', $result['authorAvatarUrl']); - $this->assertFalse($result['external']); - // Bitbucket's push payload carries no file lists - $this->assertSame([], $result['affectedFiles']); - } - /** - * Bitbucket only names the author in a raw "Name " string when the - * commit isn't linked to an account. + * Bitbucket only names the author in a raw "Name " string; a commit + * linked to an account is named by the account instead. */ public function testGetEventPushWithLinkedAuthor(): void { - $payload = $this->pushPayload(author: [ - 'raw' => 'Test User ', - 'user' => ['display_name' => 'Linked User'], - ]); - - $result = $this->vcsAdapter->getEvent('repo:push', $payload); - - $this->assertSame('Linked User', $result['headCommitAuthorName']); - $this->assertSame('test@example.com', $result['headCommitAuthorEmail']); - } - - public function testGetEventPushDetectsBranchCreated(): void - { - $result = $this->vcsAdapter->getEvent('repo:push', $this->pushPayload(created: true)); - - $this->assertTrue($result['branchCreated']); - $this->assertFalse($result['branchDeleted']); - $this->assertSame('main', $result['branch']); - } - - public function testGetEventPushDetectsBranchDeleted(): void - { - // A deleted branch is reported with no new state, only the old one - $payload = json_encode([ - 'actor' => ['links' => []], - 'repository' => [ - 'uuid' => '{11111111-2222-3333-4444-555555555555}', - 'name' => 'test-repo', - 'full_name' => 'test-workspace/test-repo', - 'workspace' => ['slug' => 'test-workspace'], - 'links' => ['html' => ['href' => 'https://bitbucket.org/test-workspace/test-repo']], - ], + $payload = (string) json_encode([ + 'actor' => $this->eventActor(), + 'repository' => $this->eventRepository(), 'push' => [ - 'changes' => [ - [ - 'new' => null, - 'old' => ['type' => 'branch', 'name' => 'feature', 'target' => ['hash' => 'abc123']], - 'created' => false, - 'closed' => true, + 'changes' => [[ + 'new' => [ + 'type' => 'branch', + 'name' => static::$defaultBranch, + 'target' => [ + 'hash' => static::EVENT_COMMIT_HASH, + 'author' => [ + 'raw' => static::EVENT_AUTHOR_NAME . ' <' . static::EVENT_AUTHOR_EMAIL . '>', + 'user' => ['display_name' => 'Linked User'], + ], + ], ], - ], + ]], ], ]); - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } + $result = $this->vcsAdapter->getEvent(static::$pushEventName, $payload); - $result = $this->vcsAdapter->getEvent('repo:push', $payload); - - $this->assertFalse($result['branchCreated']); - $this->assertTrue($result['branchDeleted']); - $this->assertSame('feature', $result['branch']); - $this->assertSame('', $result['commitHash']); - } - - /** - * A repositoryId has to be resolvable by the adapter that reported it. - */ - public function testGetEventReportsResolvableRepositoryId(): void - { - $repositoryName = 'test-event-repository-id-' . \uniqid(); - $created = $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $payload = json_encode([ - 'repository' => [ - 'uuid' => $created['uuid'] ?? '', - 'name' => $repositoryName, - 'full_name' => $created['full_name'] ?? '', - 'workspace' => ['slug' => $this->ownerPath()], - ], - 'push' => ['changes' => [['new' => ['type' => 'branch', 'name' => 'main']]]], - ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $event = $this->vcsAdapter->getEvent('repo:push', $payload); - - $this->assertSame($created['id'], $event['repositoryId']); - $this->assertSame($repositoryName, $this->vcsAdapter->getRepositoryName($event['repositoryId'])); - } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); - } + $this->assertSame('Linked User', $result['headCommitAuthorName']); + $this->assertSame(static::EVENT_AUTHOR_EMAIL, $result['headCommitAuthorEmail']); } /** - * Bitbucket reports every ref a push touched in one delivery. + * Bitbucket batches every ref a push touched into one delivery, and the + * shared event shape describes a branch push, so tags are left out. */ public function testGetEventsReportsEveryPushedBranch(): void { - $payload = json_encode([ - 'actor' => ['links' => []], - 'repository' => [ - 'name' => 'test-repo', - 'full_name' => 'test-workspace/test-repo', - 'workspace' => ['slug' => 'test-workspace'], - 'links' => ['html' => ['href' => 'https://bitbucket.org/test-workspace/test-repo']], - ], + $payload = (string) json_encode([ + 'actor' => $this->eventActor(), + 'repository' => $this->eventRepository(), 'push' => [ 'changes' => [ ['new' => ['type' => 'branch', 'name' => 'main', 'target' => ['hash' => 'aaa111']], 'created' => false, 'closed' => false], @@ -422,57 +387,31 @@ public function testGetEventsReportsEveryPushedBranch(): void ], ]); - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - /** @var Bitbucket $adapter */ $adapter = $this->vcsAdapter; - $events = $adapter->getEvents('repo:push', $payload); + $events = $adapter->getEvents(static::$pushEventName, $payload); - // The tag is left out; the shared event shape describes a branch push $this->assertCount(2, $events); $this->assertSame(['main', 'feature'], array_column($events, 'branch')); $this->assertSame(['aaa111', 'ccc333'], array_column($events, 'commitHash')); $this->assertTrue($events[1]['branchCreated']); // getEvent() reports the first of them - $this->assertSame($events[0], $adapter->getEvent('repo:push', $payload)); + $this->assertSame($events[0], $adapter->getEvent(static::$pushEventName, $payload)); } /** - * A tag-only push has no branch to report. + * A tag-only push has no branch to report at all. */ public function testGetEventTagPushIsNotReportedAsBranch(): void { - $payload = json_encode([ - 'repository' => ['name' => 'test-repo', 'full_name' => 'test-workspace/test-repo'], + $payload = (string) json_encode([ + 'repository' => $this->eventRepository(), 'push' => ['changes' => [['new' => ['type' => 'tag', 'name' => 'v1.0.0', 'target' => ['hash' => 'aaa111']]]]], ]); - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - $this->assertSame([], $this->vcsAdapter->getEvent('repo:push', $payload)); - } - - public function testGetEventPullRequest(): void - { - $result = $this->vcsAdapter->getEvent('pullrequest:created', $this->pullRequestPayload()); - - $this->assertIsArray($result); - $this->assertSame('feature', $result['branch']); - $this->assertSame('https://bitbucket.org/test-workspace/test-repo/branch/feature', $result['branchUrl']); - $this->assertSame('opened', $result['action']); - $this->assertFalse($result['external']); - $this->assertSame(1, $result['pullRequestNumber']); - // The routable identifier, matching what getRepositoryName() resolves - $this->assertSame('test-workspace/test-repo', $result['repositoryId']); - $this->assertSame('test-repo', $result['repositoryName']); - $this->assertSame('abc123', $result['commitHash']); - $this->assertSame('https://bitbucket.org/test-workspace/test-repo/commits/abc123', $result['headCommitUrl']); + $this->assertSame([], $this->vcsAdapter->getEvent(static::$pushEventName, $payload)); } public function testGetEventPullRequestActionMapping(): void @@ -491,100 +430,33 @@ public function testGetEventPullRequestActionMapping(): void } } - public function testGetEventPullRequestDetectsExternal(): void - { - $result = $this->vcsAdapter->getEvent( - 'pullrequest:created', - $this->pullRequestPayload(sourceRepositoryId: '{99999999-2222-3333-4444-555555555555}') - ); - - $this->assertTrue($result['external']); - } - /** - * @param array|null $author + * The repository id an event reports has to be one the adapter can resolve, + * which for Bitbucket means the pair it routes on rather than the uuid the + * payload also carries. */ - private function pushPayload(?array $author = null, bool $created = false): string + public function testGetEventReportsResolvableRepositoryId(): void { - $payload = json_encode([ - 'actor' => [ - 'display_name' => 'Tester', - 'links' => [ - 'html' => ['href' => 'https://bitbucket.org/tester'], - 'avatar' => ['href' => 'https://bitbucket.org/account/tester/avatar/'], - ], - ], - 'repository' => [ - 'uuid' => '{11111111-2222-3333-4444-555555555555}', - 'name' => 'test-repo', - 'full_name' => 'test-workspace/test-repo', - 'workspace' => ['slug' => 'test-workspace'], - 'links' => ['html' => ['href' => 'https://bitbucket.org/test-workspace/test-repo']], - ], - 'push' => [ - 'changes' => [ - [ - 'created' => $created, - 'closed' => false, - 'old' => $created ? null : ['type' => 'branch', 'name' => 'main'], - 'new' => [ - 'type' => 'branch', - 'name' => 'main', - 'target' => [ - 'hash' => 'abc123', - 'message' => 'Test commit', - 'author' => $author ?? ['raw' => 'Test User '], - 'links' => ['html' => ['href' => 'https://bitbucket.org/test-workspace/test-repo/commits/abc123']], - ], - ], - ], - ], - ], - ]); - - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } - - return $payload; - } + $repositoryName = 'test-event-repository-id-' . \uniqid(); + $created = $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - private function pullRequestPayload(string $sourceRepositoryId = '{11111111-2222-3333-4444-555555555555}'): string - { - $payload = json_encode([ - 'actor' => [ - 'links' => [ - 'html' => ['href' => 'https://bitbucket.org/tester'], - 'avatar' => ['href' => 'https://bitbucket.org/account/tester/avatar/'], - ], - ], - 'repository' => [ - 'uuid' => '{11111111-2222-3333-4444-555555555555}', - 'name' => 'test-repo', - 'full_name' => 'test-workspace/test-repo', - 'workspace' => ['slug' => 'test-workspace'], - 'links' => ['html' => ['href' => 'https://bitbucket.org/test-workspace/test-repo']], - ], - 'pullrequest' => [ - 'id' => 1, - 'title' => 'Test PR', - 'state' => 'OPEN', - 'source' => [ - 'branch' => ['name' => 'feature'], - 'commit' => ['hash' => 'abc123'], - 'repository' => ['uuid' => $sourceRepositoryId], - ], - 'destination' => [ - 'branch' => ['name' => 'main'], - 'repository' => ['uuid' => '{11111111-2222-3333-4444-555555555555}'], + try { + $payload = (string) json_encode([ + 'repository' => [ + 'uuid' => $created['uuid'] ?? '', + 'name' => $repositoryName, + 'full_name' => $created['full_name'] ?? '', + 'workspace' => ['slug' => $this->ownerPath()], ], - ], - ]); + 'push' => ['changes' => [['new' => ['type' => 'branch', 'name' => static::$defaultBranch]]]], + ]); - if ($payload === false) { - $this->fail('Failed to encode JSON payload'); - } + $event = $this->vcsAdapter->getEvent(static::$pushEventName, $payload); - return $payload; + $this->assertSame($this->repositoryIdOf($created), $event['repositoryId']); + $this->assertSame($repositoryName, $this->vcsAdapter->getRepositoryName($event['repositoryId'])); + } finally { + $this->discardRepositories($repositoryName); + } } } diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 032585ff..831818ad 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -12,6 +12,10 @@ abstract class Base extends TestCase { + /** + * Facts the webhook payload builders below carry, asserted back out of the + * normalized event. Bitbucket overrides the repository id, having none. + */ protected const EVENT_REPOSITORY_ID = '123'; protected const EVENT_REPOSITORY_NAME = 'test-repo'; @@ -129,6 +133,14 @@ abstract class Base extends TestCase protected static bool $supportsNamespaceListing = true; + protected static bool $supportsPresignedUrls = true; + + /** + * Whether a push event names the files it touched. Bitbucket's payload + * carries no file lists at all. + */ + protected static bool $reportsAffectedFilesInPushEvent = true; + /** * Whether the provider computes language stats out of band. GitHub does, * with no guaranteed turnaround, so a repository that still has none says @@ -274,6 +286,21 @@ protected function isPrivate(array $repository): bool return $repository['private']; } + /** + * Id the provider routes a repository by, as createRepository() reports it. + * Numeric for every provider but Bitbucket, which has no repository ids and + * routes by "workspace/slug" instead. + * + * @param array $repository + */ + protected function repositoryIdOf(array $repository): string + { + $this->assertArrayHasKey('id', $repository); + $this->assertIsNumeric($repository['id']); + + return (string) $repository['id']; + } + /** * Number of a pull request, as every provider but GitLab reports it. * @@ -533,12 +560,7 @@ public function testGetRepositoryName(): void $created = $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); try { - $this->assertIsArray($created); - $this->assertArrayHasKey('id', $created); - $this->assertIsNumeric($created['id']); - $repositoryId = (string) $created['id']; - - $result = $this->vcsAdapter->getRepositoryName($repositoryId); + $result = $this->vcsAdapter->getRepositoryName($this->repositoryIdOf($created)); $this->assertIsString($result); $this->assertSame($repositoryName, $result); @@ -1115,14 +1137,13 @@ public function testGetOwnerName(): void $created = $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); try { - $this->assertIsArray($created); - $this->assertArrayHasKey('id', $created); - $this->assertIsNumeric($created['id']); - $repositoryId = (int) $created['id']; - - // GitHub resolves the owner from the installation, the others from the - // repository, so pass both and let each use what it reads - $this->assertSame($this->ownerPath(), $this->vcsAdapter->getOwnerName(static::$installationId, $repositoryId)); + // GitHub resolves the owner from the installation and Bitbucket from + // the account its token belongs to, the others from the repository, so + // pass both and let each use what it reads + $this->assertSame( + $this->ownerPath(), + $this->vcsAdapter->getOwnerName(static::$installationId, (int) $this->repositoryIdOf($created)) + ); } finally { $this->discardRepositories($repositoryName); } @@ -1575,6 +1596,8 @@ public function testValidateWebhookEvent(): void public function testGetRepositoryPresignedUrl(): void { + $this->skipUnlessSupported(static::$supportsPresignedUrls, 'presigned archive urls'); + $repositoryName = 'test-presigned-url-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1598,6 +1621,8 @@ public function testGetRepositoryPresignedUrl(): void public function testGetRepositoryPresignedUrlWithInvalidFormat(): void { + $this->skipUnlessSupported(static::$supportsPresignedUrls, 'presigned archive urls'); + $this->expectException(Exception::class); $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, 'some-repo', static::$defaultBranch, 'invalid'); } @@ -1674,6 +1699,7 @@ public function testWebhookPushEvent(): void public function testWebhookPullRequestEvent(): void { + $this->skipUnlessSupported(static::$supportsWebhookDelivery, 'webhook delivery to the test catcher'); $this->skipUnlessSupported(static::$supportsPullRequestCreation, 'creating pull requests'); $repositoryName = 'test-webhook-pr-' . \uniqid(); @@ -2318,19 +2344,22 @@ public function testGetEventPush(): void ); $this->assertSame(static::$defaultBranch, $result['branch']); - $this->assertSame(self::EVENT_REPOSITORY_ID, $result['repositoryId']); - $this->assertSame(self::EVENT_REPOSITORY_NAME, $result['repositoryName']); - $this->assertSame(self::EVENT_OWNER, $result['owner']); - $this->assertSame(self::EVENT_COMMIT_HASH, $result['commitHash']); - $this->assertSame(self::EVENT_COMMIT_MESSAGE, $result['headCommitMessage']); - $this->assertSame(self::EVENT_AUTHOR_NAME, $result['headCommitAuthorName']); - $this->assertSame(self::EVENT_AUTHOR_EMAIL, $result['headCommitAuthorEmail']); + $this->assertSame(static::EVENT_REPOSITORY_ID, $result['repositoryId']); + $this->assertSame(static::EVENT_REPOSITORY_NAME, $result['repositoryName']); + $this->assertSame(static::EVENT_OWNER, $result['owner']); + $this->assertSame(static::EVENT_COMMIT_HASH, $result['commitHash']); + $this->assertSame(static::EVENT_COMMIT_MESSAGE, $result['headCommitMessage']); + $this->assertSame(static::EVENT_AUTHOR_NAME, $result['headCommitAuthorName']); + $this->assertSame(static::EVENT_AUTHOR_EMAIL, $result['headCommitAuthorEmail']); $this->assertNotEmpty($result['headCommitUrl']); $this->assertNotEmpty($result['repositoryUrl']); $this->assertNotEmpty($result['branchUrl']); $this->assertFalse($result['branchCreated']); $this->assertFalse($result['branchDeleted']); - $this->assertEqualsCanonicalizing(['file1.txt', 'file2.txt', 'file3.txt'], $result['affectedFiles']); + $this->assertEqualsCanonicalizing( + static::$reportsAffectedFilesInPushEvent ? ['file1.txt', 'file2.txt', 'file3.txt'] : [], + $result['affectedFiles'] + ); } public function testGetEventPushDetectsBranchCreated(): void @@ -2360,12 +2389,12 @@ public function testGetEventPullRequest(): void $result = $this->vcsAdapter->getEvent(static::$pullRequestEventName, $this->pullRequestPayload()); $this->assertSame('opened', $result['action']); - $this->assertSame(self::EVENT_HEAD_BRANCH, $result['branch']); - $this->assertSame(self::EVENT_PULL_REQUEST_NUMBER, $result['pullRequestNumber']); - $this->assertSame(self::EVENT_REPOSITORY_ID, $result['repositoryId']); - $this->assertSame(self::EVENT_REPOSITORY_NAME, $result['repositoryName']); - $this->assertSame(self::EVENT_OWNER, $result['owner']); - $this->assertSame(self::EVENT_COMMIT_HASH, $result['commitHash']); + $this->assertSame(static::EVENT_HEAD_BRANCH, $result['branch']); + $this->assertSame(static::EVENT_PULL_REQUEST_NUMBER, $result['pullRequestNumber']); + $this->assertSame(static::EVENT_REPOSITORY_ID, $result['repositoryId']); + $this->assertSame(static::EVENT_REPOSITORY_NAME, $result['repositoryName']); + $this->assertSame(static::EVENT_OWNER, $result['owner']); + $this->assertSame(static::EVENT_COMMIT_HASH, $result['commitHash']); $this->assertFalse($result['external']); } From 1eef616f235bea319d613656e7444c83eb6954af Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 17:09:43 +0530 Subject: [PATCH 05/34] Fold the presigned-url skip into Base's generic test --- tests/VCS/Adapter/BitbucketTest.php | 6 ------ tests/VCS/Base.php | 7 ++++++- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 4e511ca9..4d592396 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -302,12 +302,6 @@ public function testUpdateCommitStatusDefaultsUrlToCommit(): void } } - public function testGetRepositoryPresignedUrlIsUnsupported(): void - { - $this->expectException(\Exception::class); - $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, 'some-repo', static::$defaultBranch); - } - /** * Bitbucket identifies a webhook by uuid rather than by a numeric id. */ diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 831818ad..a3708f29 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -1596,7 +1596,12 @@ public function testValidateWebhookEvent(): void public function testGetRepositoryPresignedUrl(): void { - $this->skipUnlessSupported(static::$supportsPresignedUrls, 'presigned archive urls'); + if (!static::$supportsPresignedUrls) { + $this->expectException(Exception::class); + $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, 'some-repo', static::$defaultBranch); + + return; + } $repositoryName = 'test-presigned-url-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); From 2ffe0a4081fc04607108018e3f3150a0456db99e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 17:16:01 +0530 Subject: [PATCH 06/34] Promote getEvents() to the shared contract, wrapping getEvent() by default --- src/VCS/Adapter.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index 367c068d..f406ad18 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -224,6 +224,22 @@ public function validateWebhookEvent(string $payload, string $signature, string */ abstract public function getEvent(string $event, string $payload): array; + /** + * Parses a webhook payload into every event it describes. + * + * Default wraps getEvent(), the one event every provider's payload can + * describe. Override for a provider whose payload can batch several (e.g. + * Bitbucket, which reports every ref a push touched in one delivery). + * + * @param string $event Type of event: push, pull_request etc + * @param string $payload The webhook payload received from Git provider + * @return array> Parsed payloads as json objects + */ + public function getEvents(string $event, string $payload): array + { + return [$this->getEvent($event, $payload)]; + } + /** * HTTP header name carrying the webhook event type (e.g. 'x-github-event'). */ From a3c776f521f81e5cb6dbceba0a2ce687de479a1a Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 17:25:11 +0530 Subject: [PATCH 07/34] Trim the Bitbucket test to what only it can cover --- tests/VCS/Adapter/BitbucketTest.php | 92 ++++++----------------------- 1 file changed, 17 insertions(+), 75 deletions(-) diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 4d592396..3badffdd 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -30,41 +30,28 @@ class BitbucketTest extends Base protected static string $pushEventName = 'repo:push'; protected static string $pullRequestEventName = 'pullrequest:created'; - /** - * Bitbucket has no app installations, and no repository to resolve an owner - * from either - getOwnerName() reports the account the token belongs to. - */ protected static bool $supportsInstallationRepository = false; - protected static bool $resolvesOwnerFromRepositoryId = false; - - /** - * Bitbucket signs no archive urls, runs no checks, groups repositories in - * workspaces rather than namespaces (see testListWorkspaces below), and has - * a repository's language set by hand instead of computing it. - */ protected static bool $supportsPresignedUrls = false; protected static bool $supportsCheckRuns = false; - protected static bool $supportsNamespaceListing = false; protected static bool $supportsRepositoryLanguages = false; + protected static bool $reportsAffectedFilesInPushEvent = false; - /** - * Bitbucket looks accounts up by uuid rather than by handle, so the shared - * lookup does not apply; testGetUser below covers it instead. - */ + // Bitbucket has no repository to resolve an owner from; getOwnerName() + // reports the account the token belongs to + protected static bool $resolvesOwnerFromRepositoryId = false; + + // Repositories group into workspaces rather than namespaces, covered by + // testListWorkspaces below + protected static bool $supportsNamespaceListing = false; + + // Accounts are looked up by uuid rather than by handle, covered by + // testGetUser below protected static bool $supportsUserLookup = false; - /** - * Bitbucket Cloud only delivers webhooks to publicly reachable urls, so it - * cannot reach the test catcher. testCreateWebhook below covers the API side - * of a subscription. - */ + // Bitbucket Cloud only delivers webhooks to publicly reachable urls, so it + // cannot reach the test catcher; testCreateWebhook covers the API side protected static bool $supportsWebhookDelivery = false; - /** - * Bitbucket's push payload carries no per-commit file lists. - */ - protected static bool $reportsAffectedFilesInPushEvent = false; - protected function signWebhookPayload(string $payload, string $secret): string { return 'sha256=' . hash_hmac('sha256', $payload, $secret); @@ -337,27 +324,11 @@ public function testCreateWebhook(): void */ public function testGetEventPushWithLinkedAuthor(): void { - $payload = (string) json_encode([ - 'actor' => $this->eventActor(), - 'repository' => $this->eventRepository(), - 'push' => [ - 'changes' => [[ - 'new' => [ - 'type' => 'branch', - 'name' => static::$defaultBranch, - 'target' => [ - 'hash' => static::EVENT_COMMIT_HASH, - 'author' => [ - 'raw' => static::EVENT_AUTHOR_NAME . ' <' . static::EVENT_AUTHOR_EMAIL . '>', - 'user' => ['display_name' => 'Linked User'], - ], - ], - ], - ]], - ], - ]); + $payload = json_decode($this->pushPayload(static::$defaultBranch), true); + $this->assertIsArray($payload); + $payload['push']['changes'][0]['new']['target']['author']['user'] = ['display_name' => 'Linked User']; - $result = $this->vcsAdapter->getEvent(static::$pushEventName, $payload); + $result = $this->vcsAdapter->getEvent(static::$pushEventName, (string) json_encode($payload)); $this->assertSame('Linked User', $result['headCommitAuthorName']); $this->assertSame(static::EVENT_AUTHOR_EMAIL, $result['headCommitAuthorEmail']); @@ -424,33 +395,4 @@ public function testGetEventPullRequestActionMapping(): void } } - /** - * The repository id an event reports has to be one the adapter can resolve, - * which for Bitbucket means the pair it routes on rather than the uuid the - * payload also carries. - */ - public function testGetEventReportsResolvableRepositoryId(): void - { - $repositoryName = 'test-event-repository-id-' . \uniqid(); - $created = $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $payload = (string) json_encode([ - 'repository' => [ - 'uuid' => $created['uuid'] ?? '', - 'name' => $repositoryName, - 'full_name' => $created['full_name'] ?? '', - 'workspace' => ['slug' => $this->ownerPath()], - ], - 'push' => ['changes' => [['new' => ['type' => 'branch', 'name' => static::$defaultBranch]]]], - ]); - - $event = $this->vcsAdapter->getEvent(static::$pushEventName, $payload); - - $this->assertSame($this->repositoryIdOf($created), $event['repositoryId']); - $this->assertSame($repositoryName, $this->vcsAdapter->getRepositoryName($event['repositoryId'])); - } finally { - $this->discardRepositories($repositoryName); - } - } } From aa82e107776bee13eced92011f9193d1763052f6 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 17:30:51 +0530 Subject: [PATCH 08/34] Document getEvents() for batched webhook deliveries --- docs/add-new-vcs-adapter.md | 17 +++++++++++++++++ src/VCS/Adapter/Git/Bitbucket.php | 5 +++++ 2 files changed, 22 insertions(+) diff --git a/docs/add-new-vcs-adapter.md b/docs/add-new-vcs-adapter.md index e0f1fb93..dfea5301 100644 --- a/docs/add-new-vcs-adapter.md +++ b/docs/add-new-vcs-adapter.md @@ -76,6 +76,23 @@ $vcs->initializeVariables($installationId, $privateKey, $appId); Only include dependencies strictly necessary for the adapter, preferably official PHP libraries, if available. +#### Webhook deliveries describing more than one event + +`getEvent()` returns the single event a payload describes, which is all most +providers ever send. A provider that batches several refs into one delivery +should override `getEvents()` as well, so a consumer can read all of them: + +```php +public function getEvents(string $event, string $payload): array +{ + // one entry per ref the delivery touched +} +``` + +The default `getEvents()` wraps `getEvent()`, so an adapter that never batches +needs no override. Consumers that must not miss a ref should call `getEvents()` +rather than `getEvent()` — the latter reports only the first event of a batch. + ### Testing with Docker 🛠️ The existing test suite is helpful when developing a new VCS adapter. Use official Docker images from trusted sources. Add new tests for your new VCS adapter in `tests/VCS/Adapter/VCSTest.php` test class. The specific `docker-compose` command for testing can be found in the [README](/README.md#tests). diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 4a3f6782..9b1f2c29 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -1192,6 +1192,11 @@ public function generateCloneCommand(string $owner, string $repositoryName, stri return implode(' && ', $commands); } + /** + * Reports the first event a payload describes, per the shared contract. A + * Bitbucket push can touch several refs in one delivery, so a caller that + * must not miss one reads getEvents() below instead. + */ public function getEvent(string $event, string $payload): array { return $this->getEvents($event, $payload)[0] ?? []; From dbd51fb21d3d85841d3dd58c9eb94f57a0631c6b Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 17:36:14 +0530 Subject: [PATCH 09/34] Cover owner lookup without a repository id in Base --- tests/VCS/Adapter/BitbucketTest.php | 9 --------- tests/VCS/Base.php | 12 +++++++++++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 3badffdd..a993300f 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -198,15 +198,6 @@ private function eventActor(): array ]; } - /** - * Bitbucket reads the owner off the account its token belongs to, so no - * repository - not even one that does not exist - changes the answer. - */ - public function testGetOwnerNameIgnoresRepositoryId(): void - { - $this->assertSame($this->ownerPath(), $this->vcsAdapter->getOwnerName('', 999999999)); - } - /** * Bitbucket looks accounts up by uuid, and reports the handle as `nickname` * for every account but the authenticated one. diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index a3708f29..fad06f47 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -1661,7 +1661,17 @@ public function testGetInstallationRepository(): void public function testGetOwnerNameWithInvalidRepositoryId(): void { - $this->skipUnlessSupported(static::$resolvesOwnerFromRepositoryId, 'resolving an owner from a repository id'); + if (!static::$resolvesOwnerFromRepositoryId) { + // GitHub reads the owner off the installation and Bitbucket off the + // account its token belongs to, so an id that resolves to nothing + // does not change the answer + $this->assertSame( + $this->ownerPath(), + $this->vcsAdapter->getOwnerName(static::$installationId, 999999999) + ); + + return; + } $this->expectException(static::$repositoryNotFoundException); $this->vcsAdapter->getOwnerName('', 999999999); From a13125878c07be314afc9f8128a6878b9132f31b Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 21:30:32 +0530 Subject: [PATCH 10/34] fix: resolve Bitbucket getOwnerName() via /user/workspaces Same bug as the OAuth2 side, independently: username/nickname aren't workspace identifiers for accounts migrated to Atlassian's unified identity, and the account UUID doesn't double as the workspace UUID. Silently returned zero repositories rather than an error, since searchRepositories() treats any 4xx as an empty result. Resolves the workspace via /user/workspaces -- the endpoint Atlassian's migration guidance names as the replacement for the cross-workspace /workspaces listing CHANGE-2770 removed -- falling back to the old username/nickname behavior only if that call fails. --- src/VCS/Adapter/Git/Bitbucket.php | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 9b1f2c29..9549a4d6 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -1134,13 +1134,39 @@ public function listWorkspaces(int $page, int $per_page): array * Bitbucket has no installations and no numeric repository ids, so * $installationId and $repositoryId are both unused: the owner is always * the workspace of the account the token belongs to. + * + * `/user`'s `username` (old accounts) or `nickname` (accounts migrated to + * Atlassian's unified identity) are display handles, not workspace + * identifiers -- for migrated accounts `nickname` is an opaque value + * Bitbucket's repository API doesn't recognize as a workspace, silently + * returning zero repositories rather than an error. The account's own + * UUID doesn't double as its workspace's UUID either -- confirmed live, + * the two are unrelated. The workspace is instead resolved via + * `/user/workspaces`, the endpoint Atlassian's migration guidance names + * as the user-scoped replacement for the cross-workspace `/workspaces` + * listing CHANGE-2770 removed. */ public function getOwnerName(string $installationId, ?int $repositoryId = null): string { + $response = $this->call(self::METHOD_GET, '/user/workspaces', ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode < 400) { + $body = $response['body'] ?? []; + $values = is_array($body) ? ($body['values'] ?? []) : []; + $first = $values[0] ?? []; + // Some Bitbucket user-scoped list endpoints wrap the resource + // under its own key (e.g. the older /permissions/workspaces + // did); accept either shape rather than assume this one is flat. + $slug = $first['slug'] ?? ($first['workspace']['slug'] ?? ''); + if (!empty($slug)) { + return (string) $slug; + } + } + $user = $this->getAuthenticatedUser(); - // A personal workspace is named after its account. `username` is only - // reported for the authenticated account itself, hence the fallback. return (string) ($user['username'] ?? ($user['nickname'] ?? '')); } From 9d62da5242e96195452aa171b8f8afc7370602bc Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 21:50:27 +0530 Subject: [PATCH 11/34] fix: don't let a missing file crash the worker in getRepositoryContent() resolveRef() above already catches Exception and converts it to FileNotFound() for the expected "file/ref doesn't exist" case, but the two call() invocations below it didn't -- so a 404 whose response body doesn't decode as JSON (a real, common case: not every repo has package.json) propagated as an uncaught fatal, crashing the whole Swoole worker (confirmed live) rather than resulting in a normal FileNotFound for this one lookup. --- src/VCS/Adapter/Git/Bitbucket.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 9549a4d6..607bd0bc 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -436,7 +436,15 @@ public function getRepositoryContent(string $owner, string $repositoryName, stri $path = $this->normalizeRepositoryPath($path); $url = "/repositories/{$owner}/{$repositoryName}/src/" . rawurlencode($ref) . '/' . $this->encodeRepositoryPath($path); - $metaResponse = $this->call(self::METHOD_GET, $url . '?format=meta', ['Authorization' => 'Bearer ' . $this->accessToken]); + // A missing file is the expected, common case here (not every repo + // has e.g. package.json) -- call() throws on a non-JSON/empty body, + // which a 404 can legitimately have, so that has to be caught here + // too rather than left to propagate as an uncaught fatal. + try { + $metaResponse = $this->call(self::METHOD_GET, $url . '?format=meta', ['Authorization' => 'Bearer ' . $this->accessToken]); + } catch (Exception $e) { + throw new FileNotFound(); + } $metaHeaders = $metaResponse['headers'] ?? []; if (($metaHeaders['status-code'] ?? 0) !== 200) { @@ -450,7 +458,11 @@ public function getRepositoryContent(string $owner, string $repositoryName, stri // Bitbucket serves file contents raw, typed after the file extension, so // don't let the response be decoded as JSON. - $contentResponse = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [], false); + try { + $contentResponse = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [], false); + } catch (Exception $e) { + throw new FileNotFound(); + } $contentHeaders = $contentResponse['headers'] ?? []; if (($contentHeaders['status-code'] ?? 0) !== 200) { From d7f53dae360706367b32e7794f77408fb72e791f Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Thu, 30 Jul 2026 22:10:37 +0530 Subject: [PATCH 12/34] feat: implement getRepositoryPresignedUrl() for Bitbucket Was missing entirely, throwing the base Git class's "not supported" default -- confirmed live, this crashed VCS site/function deployments outright (Compute/Base.php calls it unconditionally for every provider). Bitbucket's archive download lives on the browser host, not the API host, and only supports zip/gz/bz2 (no "tarball" extension distinct from gz). Auth is URL-embedded the same way generateCloneCommand() already does it, since this URL is handed off for a plain download rather than called with a bearer header. --- src/VCS/Adapter/Git/Bitbucket.php | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 607bd0bc..1a93fa26 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -1182,6 +1182,34 @@ public function getOwnerName(string $installationId, ?int $repositoryId = null): return (string) ($user['username'] ?? ($user['nickname'] ?? '')); } + /** + * @link https://support.atlassian.com/bitbucket-cloud/kb/how-to-download-repositories-using-the-api/ + * + * Unlike GitHub/GitLab, this archive download lives on the browser host + * (bitbucket.org/{owner}/{repo}/get/{ref}.{ext}), not the API host, and + * only supports zip/gz/bz2 -- there's no distinct "tarball" extension. + * Auth is embedded the same way generateCloneCommand() embeds it for git + * clones, since this URL is handed off for a plain download rather than + * called with a bearer header. + */ + public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string + { + $extension = match ($format) { + 'tarball' => 'gz', + 'zipball' => 'zip', + default => throw new Exception("Invalid archive format: {$format}. Use 'tarball' or 'zipball'."), + }; + + $baseUrl = $this->bitbucketUrl; + if (!empty($this->accessToken)) { + $baseUrl = str_replace('://', '://x-token-auth:' . urlencode($this->accessToken) . '@', $this->bitbucketUrl); + } + + $refSegment = !empty($ref) ? $ref : 'HEAD'; + + return "{$baseUrl}/{$owner}/{$repositoryName}/get/" . rawurlencode($refSegment) . ".{$extension}"; + } + public function generateCloneCommand(string $owner, string $repositoryName, string $version, string $versionType, string $directory, string $rootDirectory): string { if (empty($rootDirectory) || $rootDirectory === '/') { From 070f703820ece61c0232614a07dddcd359eb81f7 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 31 Jul 2026 13:52:20 +0530 Subject: [PATCH 13/34] fix: correct Bitbucket archive format, guard webhook uuid, cover presigned urls Follow-ups from review of the presigned-url commit: - Archive extension was `.gz`; verified against a live repo that `.tar.gz` serves the same gzipped tarball and matches the shared contract's default $presignedTarballFragment, so no override is needed. - Ref is now encoded keeping slashes, as Gitea and GitHub do, so nested branch names (feature/foo) resolve. - Documented why the credential travels as basic userinfo rather than the query parameter GitLab and Gitea use: Bitbucket answers this directly instead of redirecting to a signed url (so GitHub's redirect-following approach isn't available), and its ?access_token= form was removed in CHANGE-3052. - createWebhook() threw away a missing uuid, returning '' and leaving the caller unable to delete the hook it just created. Now throws, mirroring GitHub::createWebhook()'s guard. - Dropped $supportsPresignedUrls = false so the two shared contract tests run instead of asserting the method throws -- it no longer does. Folded the tag-only push case into the multi-ref test rather than keeping a separate test for the same rule. --- src/VCS/Adapter/Git/Bitbucket.php | 32 +++++++++++++++++++---------- tests/VCS/Adapter/BitbucketTest.php | 13 ++++-------- 2 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 1a93fa26..788fbe4a 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -1009,9 +1009,12 @@ public function createWebhook(string $owner, string $repositoryName, string $url throw new Exception("Failed to create webhook: HTTP {$statusCode} - " . json_encode($response['body'] ?? []), $statusCode); } - $responseBody = $response['body'] ?? []; + $uuid = $response['body']['uuid'] ?? null; + if ($uuid === null || $uuid === '') { + throw new Exception('Webhook created but response did not include a uuid'); + } - return $responseBody['uuid'] ?? ''; + return (string) $uuid; } /** @@ -1185,17 +1188,20 @@ public function getOwnerName(string $installationId, ?int $repositoryId = null): /** * @link https://support.atlassian.com/bitbucket-cloud/kb/how-to-download-repositories-using-the-api/ * - * Unlike GitHub/GitLab, this archive download lives on the browser host - * (bitbucket.org/{owner}/{repo}/get/{ref}.{ext}), not the API host, and - * only supports zip/gz/bz2 -- there's no distinct "tarball" extension. - * Auth is embedded the same way generateCloneCommand() embeds it for git - * clones, since this URL is handed off for a plain download rather than - * called with a bearer header. + * Bitbucket serves this from the browser host rather than the API host, + * and answers it directly instead of redirecting to a signed URL, so -- + * unlike GitHub, which returns the redirect target -- the credential has + * to travel in the URL, as it does for GitLab and Gitea. + * + * It travels as HTTP Basic userinfo rather than the query parameter those + * two use: Bitbucket's documented form for this endpoint is basic auth, + * and its `?access_token=` query parameter was removed in CHANGE-3052. + * `x-token-auth` is the same scheme generateCloneCommand() below relies on. */ public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string { $extension = match ($format) { - 'tarball' => 'gz', + 'tarball' => 'tar.gz', 'zipball' => 'zip', default => throw new Exception("Invalid archive format: {$format}. Use 'tarball' or 'zipball'."), }; @@ -1205,9 +1211,13 @@ public function getRepositoryPresignedUrl(string $owner, string $repositoryName, $baseUrl = str_replace('://', '://x-token-auth:' . urlencode($this->accessToken) . '@', $this->bitbucketUrl); } - $refSegment = !empty($ref) ? $ref : 'HEAD'; + // Bitbucket resolves HEAD to the repository's default branch + $ref = empty($ref) ? 'HEAD' : $ref; + + // Encode the ref but keep slashes so nested branch names (e.g. feature/foo) still resolve + $encodedRef = \str_replace('%2F', '/', \rawurlencode($ref)); - return "{$baseUrl}/{$owner}/{$repositoryName}/get/" . rawurlencode($refSegment) . ".{$extension}"; + return "{$baseUrl}/{$owner}/{$repositoryName}/get/{$encodedRef}.{$extension}"; } public function generateCloneCommand(string $owner, string $repositoryName, string $version, string $versionType, string $directory, string $rootDirectory): string diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index a993300f..b9e91ad4 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -31,7 +31,6 @@ class BitbucketTest extends Base protected static string $pullRequestEventName = 'pullrequest:created'; protected static bool $supportsInstallationRepository = false; - protected static bool $supportsPresignedUrls = false; protected static bool $supportsCheckRuns = false; protected static bool $supportsRepositoryLanguages = false; protected static bool $reportsAffectedFilesInPushEvent = false; @@ -348,6 +347,7 @@ public function testGetEventsReportsEveryPushedBranch(): void $events = $adapter->getEvents(static::$pushEventName, $payload); + // The tag between them is left out $this->assertCount(2, $events); $this->assertSame(['main', 'feature'], array_column($events, 'branch')); $this->assertSame(['aaa111', 'ccc333'], array_column($events, 'commitHash')); @@ -355,19 +355,14 @@ public function testGetEventsReportsEveryPushedBranch(): void // getEvent() reports the first of them $this->assertSame($events[0], $adapter->getEvent(static::$pushEventName, $payload)); - } - /** - * A tag-only push has no branch to report at all. - */ - public function testGetEventTagPushIsNotReportedAsBranch(): void - { - $payload = (string) json_encode([ + // Leaving a push with nothing but tags no branch to report at all + $tagsOnly = (string) json_encode([ 'repository' => $this->eventRepository(), 'push' => ['changes' => [['new' => ['type' => 'tag', 'name' => 'v1.0.0', 'target' => ['hash' => 'aaa111']]]]], ]); - $this->assertSame([], $this->vcsAdapter->getEvent(static::$pushEventName, $payload)); + $this->assertSame([], $adapter->getEvent(static::$pushEventName, $tagsOnly)); } public function testGetEventPullRequestActionMapping(): void From 59dd3d96ac4c131a021a10f19aaf6a3817a7466d Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 31 Jul 2026 15:03:56 +0530 Subject: [PATCH 14/34] fix: stop BitbucketTest::testGetUser from shadowing Base's test The method name collided with Base::testGetUser(), so PHPUnit ran only Bitbucket's override and never reached Base's skipUnlessSupported() check -- meaning $supportsUserLookup = false correctly skipped testGetUserWithInvalidUsername but silently did nothing for testGetUser. Renamed to testGetUserByUuid so it no longer overrides a Base test by name, matching every other adapter's tests (none of which redefine a Base method -- they only add new ones or override the hook methods Base already exposes for this). --- tests/VCS/Adapter/BitbucketTest.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index b9e91ad4..1fedb83f 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -44,7 +44,7 @@ class BitbucketTest extends Base protected static bool $supportsNamespaceListing = false; // Accounts are looked up by uuid rather than by handle, covered by - // testGetUser below + // testGetUserByUuid below protected static bool $supportsUserLookup = false; // Bitbucket Cloud only delivers webhooks to publicly reachable urls, so it @@ -199,9 +199,11 @@ private function eventActor(): array /** * Bitbucket looks accounts up by uuid, and reports the handle as `nickname` - * for every account but the authenticated one. + * for every account but the authenticated one. $supportsUserLookup is false + * so Base::testGetUser() and testGetUserWithInvalidUsername() skip + * themselves rather than run against a handle Bitbucket doesn't accept. */ - public function testGetUser(): void + public function testGetUserByUuid(): void { /** @var Bitbucket $adapter */ $adapter = $this->vcsAdapter; From ed950e9e530e86b5943451f458c0b4f13be00f91 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 31 Jul 2026 15:43:06 +0530 Subject: [PATCH 15/34] fix: remove Bitbucket-only public methods that broke the generic contract Three things a consumer would have had to special-case by provider: - listWorkspaces() was a new, Bitbucket-only public method instead of an override of the existing generic listNamespaces(). Removed rather than mapped onto it: the shared test asserts both 'user' and 'group' kinds, which Bitbucket workspaces have no way to distinguish. Left unsupported, same as GitHub and Gitea already declare. - getAuthenticatedUser() was public for no reason but its own test calling it from outside the class. Made protected, matching Gitea's getAuthenticatedUserLogin() -- both used only internally by getOwnerName(). Its test (testGetUserByUuid) is dropped along with it; $supportsUserLookup = false already skips the shared user-lookup tests the same way it does for any other unsupported capability. - deleteWebhook() existed only on Bitbucket, so a caller wanting to clean up a webhook it created would have had no generic way to do it for any other provider. Promoted to the abstract Git contract alongside createWebhook()'s int|string return type, and implemented for GitHub, GitLab and Gitea (Gogs and Forgejo inherit Gitea's). Base gets a new testCreateAndDeleteWebhook() covering creation and deletion through the API alone, independent of $supportsWebhookDelivery, replacing Bitbucket's bespoke version. --- src/VCS/Adapter/Git.php | 8 +++ src/VCS/Adapter/Git/Bitbucket.php | 45 +++------------- src/VCS/Adapter/Git/GitHub.php | 18 +++++++ src/VCS/Adapter/Git/GitLab.php | 20 +++++++ src/VCS/Adapter/Git/Gitea.php | 18 +++++++ tests/VCS/Adapter/BitbucketTest.php | 84 +++-------------------------- tests/VCS/Base.php | 29 +++++++++- 7 files changed, 105 insertions(+), 117 deletions(-) diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index f0ba9bff..849ba6c8 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -86,6 +86,14 @@ abstract public function createPullRequest(string $owner, string $repositoryName */ abstract public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int|string; + /** + * Delete a webhook from a repository. + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param int|string $webhookId Webhook ID as returned by createWebhook() + */ + abstract public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool; /** * Create a tag in a repository diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 788fbe4a..870fee3b 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -1020,9 +1020,9 @@ public function createWebhook(string $owner, string $repositoryName, string $url /** * Delete a webhook from a repository. */ - public function deleteWebhook(string $owner, string $repositoryName, string $webhookUuid): bool + public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool { - $url = "/repositories/{$owner}/{$repositoryName}/hooks/" . rawurlencode($webhookUuid); + $url = "/repositories/{$owner}/{$repositoryName}/hooks/" . rawurlencode((string) $webhookId); $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); @@ -1091,11 +1091,13 @@ public function getUser(string $username): array } /** - * Account the access token belongs to. + * Account the access token belongs to. Internal to this adapter -- used + * only by getOwnerName() below, the same shape Gitea's + * getAuthenticatedUserLogin() takes for the equivalent lookup. * * @return array */ - public function getAuthenticatedUser(): array + protected function getAuthenticatedUser(): array { $response = $this->call(self::METHOD_GET, '/user', ['Authorization' => 'Bearer ' . $this->accessToken]); @@ -1110,41 +1112,6 @@ public function getAuthenticatedUser(): array return is_array($body) ? $body : []; } - /** - * Workspaces the access token can act on. - * - * @return array{items: array, total: int} - */ - public function listWorkspaces(int $page, int $per_page): array - { - $url = "/workspaces?page={$page}&pagelen={$per_page}"; - - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); - - $responseHeaders = $response['headers'] ?? []; - $statusCode = $responseHeaders['status-code'] ?? 0; - if ($statusCode >= 400) { - throw new Exception("Failed to list workspaces: HTTP {$statusCode}", $statusCode); - } - - $responseBody = $response['body'] ?? []; - $values = is_array($responseBody) ? ($responseBody['values'] ?? []) : []; - - $workspaces = []; - foreach ($values as $workspace) { - $workspaces[] = [ - 'id' => (string) ($workspace['uuid'] ?? ''), - 'name' => $workspace['name'] ?? ($workspace['slug'] ?? ''), - 'slug' => $workspace['slug'] ?? '', - ]; - } - - return [ - 'items' => $workspaces, - 'total' => (int) ($responseBody['size'] ?? \count($workspaces)), - ]; - } - /** * Bitbucket has no installations and no numeric repository ids, so * $installationId and $repositoryId are both unused: the owner is always diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 6f052061..5785105a 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -160,6 +160,24 @@ public function createWebhook(string $owner, string $repositoryName, string $url return (int) $id; } + /** + * Delete a webhook from a repository. + */ + public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool + { + $url = "/repos/{$owner}/{$repositoryName}/hooks/{$webhookId}"; + + $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => "Bearer $this->accessToken"]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to delete webhook: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode); + } + + return true; + } + /** * Create a file in a repository * diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 34416911..518509d8 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -597,6 +597,26 @@ public function createWebhook(string $owner, string $repositoryName, string $url return $responseBody['id'] ?? 0; } + /** + * Delete a webhook from a repository. + */ + public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool + { + $ownerPath = $this->getOwnerPath($owner); + $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); + $url = "/projects/{$projectPath}/hooks/{$webhookId}"; + + $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to delete webhook: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode); + } + + return true; + } + public function createComment(string $owner, string $repositoryName, int $pullRequestNumber, string $comment): string { $ownerPath = $this->getOwnerPath($owner); diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index 8d630dad..cbe2a229 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -597,6 +597,24 @@ public function createWebhook(string $owner, string $repositoryName, string $url return (int) ($response['body']['id'] ?? 0); } + /** + * Delete a webhook from a repository. + */ + public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool + { + $url = "/repos/{$owner}/{$repositoryName}/hooks/{$webhookId}"; + + $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => "token $this->accessToken"]); + + $responseHeaders = $response['headers'] ?? []; + $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; + if ($responseHeadersStatusCode >= 400) { + throw new Exception("Failed to delete webhook: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode); + } + + return true; + } + public function createComment(string $owner, string $repositoryName, int $pullRequestNumber, string $comment): string { $url = "/repos/{$owner}/{$repositoryName}/issues/{$pullRequestNumber}/comments"; diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 1fedb83f..c042e97b 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -39,16 +39,18 @@ class BitbucketTest extends Base // reports the account the token belongs to protected static bool $resolvesOwnerFromRepositoryId = false; - // Repositories group into workspaces rather than namespaces, covered by - // testListWorkspaces below + // Repositories group into workspaces, which the shared namespace shape + // (personal vs group) doesn't fit -- Bitbucket has no personal-vs-team + // distinction to report, so this is left unsupported like GitHub and Gitea protected static bool $supportsNamespaceListing = false; - // Accounts are looked up by uuid rather than by handle, covered by - // testGetUserByUuid below + // Accounts are looked up by uuid or Atlassian account id; only some + // resolve by the handle Base::testGetUser() would look up protected static bool $supportsUserLookup = false; // Bitbucket Cloud only delivers webhooks to publicly reachable urls, so it - // cannot reach the test catcher; testCreateWebhook covers the API side + // cannot reach the test catcher; testCreateAndDeleteWebhook in Base covers + // webhook creation and deletion through the API instead protected static bool $supportsWebhookDelivery = false; protected function signWebhookPayload(string $payload, string $secret): string @@ -197,49 +199,6 @@ private function eventActor(): array ]; } - /** - * Bitbucket looks accounts up by uuid, and reports the handle as `nickname` - * for every account but the authenticated one. $supportsUserLookup is false - * so Base::testGetUser() and testGetUserWithInvalidUsername() skip - * themselves rather than run against a handle Bitbucket doesn't accept. - */ - public function testGetUserByUuid(): void - { - /** @var Bitbucket $adapter */ - $adapter = $this->vcsAdapter; - - $me = $adapter->getAuthenticatedUser(); - $this->assertNotEmpty($me['uuid'] ?? ''); - - $result = $adapter->getUser($me['uuid']); - - $this->assertIsArray($result); - $this->assertSame($me['uuid'], $result['id']); - $this->assertSame($me['username'] ?? ($me['nickname'] ?? ''), $result['username']); - } - - /** - * Workspaces are Bitbucket's grouping of repositories, in place of the - * namespaces the other providers list. - */ - public function testListWorkspaces(): void - { - /** @var Bitbucket $adapter */ - $adapter = $this->vcsAdapter; - - $result = $adapter->listWorkspaces(1, 20); - - $this->assertIsArray($result); - $this->assertArrayHasKey('items', $result); - $this->assertArrayHasKey('total', $result); - $this->assertNotEmpty($result['items']); - - foreach ($result['items'] as $workspace) { - $this->assertArrayHasKey('slug', $workspace); - $this->assertNotEmpty($workspace['slug']); - } - } - /** * Bitbucket rejects a build status with no url, so the adapter points one * that was written without a url at the commit it describes. @@ -281,35 +240,6 @@ public function testUpdateCommitStatusDefaultsUrlToCommit(): void } } - /** - * Bitbucket identifies a webhook by uuid rather than by a numeric id. - */ - public function testCreateWebhook(): void - { - $repositoryName = 'test-create-webhook-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - /** @var Bitbucket $adapter */ - $adapter = $this->vcsAdapter; - - $uuid = $adapter->createWebhook( - static::$owner, - $repositoryName, - 'https://example.com/webhook', - 'secret-token', - ['push', 'pull_request'] - ); - - $this->assertIsString($uuid); - $this->assertNotEmpty($uuid); - - $this->assertTrue($adapter->deleteWebhook(static::$owner, $repositoryName, $uuid)); - } finally { - $this->discardRepositories($repositoryName); - } - } - /** * Bitbucket only names the author in a raw "Name " string; a commit * linked to an account is named by the account instead. diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index fad06f47..fae6f0ef 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -1697,7 +1697,7 @@ public function testWebhookPushEvent(): void $secret, ['push'] ); - $this->assertGreaterThan(0, $webhookId); + $this->assertNotEmpty($webhookId); $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Webhook Test', 'Initial commit'); @@ -1712,6 +1712,33 @@ public function testWebhookPushEvent(): void } } + /** + * Exercises createWebhook()/deleteWebhook() through the API alone, unlike + * testWebhookPushEvent() above, which also needs delivery to reach the + * test catcher. This covers every adapter regardless of whether its + * webhooks are reachable from the test environment. + */ + public function testCreateAndDeleteWebhook(): void + { + $repositoryName = 'test-create-delete-webhook-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $webhookId = $this->vcsAdapter->createWebhook( + static::$owner, + $repositoryName, + 'https://example.com/webhook', + 'secret-token', + ['push', 'pull_request'] + ); + $this->assertNotEmpty($webhookId); + + $this->assertTrue($this->vcsAdapter->deleteWebhook(static::$owner, $repositoryName, $webhookId)); + } finally { + $this->discardRepositories($repositoryName); + } + } + public function testWebhookPullRequestEvent(): void { $this->skipUnlessSupported(static::$supportsWebhookDelivery, 'webhook delivery to the test catcher'); From 74553e6e59358664203e2edd3fc5fa0cd159f134 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 31 Jul 2026 15:52:45 +0530 Subject: [PATCH 16/34] fix: stop testCreateAndDeleteWebhook from doubling webhook creation Ran unconditionally on every adapter alongside testWebhookPushEvent, which already creates a webhook -- doubling create-webhook calls per suite run and tripping GitHub's secondary rate limit (HTTP 403 on CI). testWebhookPushEvent now deletes the webhook it already created instead of leaving it behind; the standalone API-only test only runs for an adapter that skips webhook delivery, so it never runs alongside it. Also dropped the redundant "Delete a webhook from a repository." docblocks added to each concrete deleteWebhook() -- GitHub.php, GitLab.php and Gitea.php don't add one-line restating comments on equally simple methods like deleteRepository() or createComment(), so deleteWebhook() shouldn't either. --- src/VCS/Adapter/Git/GitHub.php | 3 --- src/VCS/Adapter/Git/GitLab.php | 3 --- src/VCS/Adapter/Git/Gitea.php | 3 --- tests/VCS/Base.php | 19 ++++++++++++++----- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index 5785105a..db951483 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -160,9 +160,6 @@ public function createWebhook(string $owner, string $repositoryName, string $url return (int) $id; } - /** - * Delete a webhook from a repository. - */ public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool { $url = "/repos/{$owner}/{$repositoryName}/hooks/{$webhookId}"; diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 518509d8..4e989580 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -597,9 +597,6 @@ public function createWebhook(string $owner, string $repositoryName, string $url return $responseBody['id'] ?? 0; } - /** - * Delete a webhook from a repository. - */ public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool { $ownerPath = $this->getOwnerPath($owner); diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index cbe2a229..ffd401a1 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -597,9 +597,6 @@ public function createWebhook(string $owner, string $repositoryName, string $url return (int) ($response['body']['id'] ?? 0); } - /** - * Delete a webhook from a repository. - */ public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool { $url = "/repos/{$owner}/{$repositoryName}/hooks/{$webhookId}"; diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index fae6f0ef..fd0ff8f9 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -1707,19 +1707,28 @@ public function testWebhookPushEvent(): void $this->assertSame($repositoryName, $event['repositoryName']); $this->assertSame($this->ownerPath(), $event['owner']); $this->assertNotEmpty($event['commitHash']); + + // Reuses the webhook above rather than creating a second one, so + // this doesn't add another create-webhook call for every adapter + // that already covers creation here. + $this->assertTrue($this->vcsAdapter->deleteWebhook(static::$owner, $repositoryName, $webhookId)); } finally { $this->discardRepositories($repositoryName); } } /** - * Exercises createWebhook()/deleteWebhook() through the API alone, unlike - * testWebhookPushEvent() above, which also needs delivery to reach the - * test catcher. This covers every adapter regardless of whether its - * webhooks are reachable from the test environment. + * Covers createWebhook()/deleteWebhook() through the API alone, for an + * adapter whose webhooks testWebhookPushEvent() can't reach to also cover + * deletion there. Skipped otherwise so this doesn't create a second + * webhook alongside the one that test already creates and deletes. */ - public function testCreateAndDeleteWebhook(): void + public function testCreateAndDeleteWebhookWithoutDelivery(): void { + if (static::$supportsWebhookDelivery) { + $this->markTestSkipped('covered by testWebhookPushEvent()'); + } + $repositoryName = 'test-create-delete-webhook-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); From 103302af1550818586c99b49e4adc3050edf302b Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 31 Jul 2026 15:54:54 +0530 Subject: [PATCH 17/34] fix: match Base's existing skip-helper pattern for the webhook test Used a raw if/markTestSkipped with its own message instead of the skipUnlessSupported() helper every other capability check in this file already goes through. Also dropped an inline comment restating what the test's own docblock already says. --- tests/VCS/Base.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index fd0ff8f9..993c4b64 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -1708,9 +1708,6 @@ public function testWebhookPushEvent(): void $this->assertSame($this->ownerPath(), $event['owner']); $this->assertNotEmpty($event['commitHash']); - // Reuses the webhook above rather than creating a second one, so - // this doesn't add another create-webhook call for every adapter - // that already covers creation here. $this->assertTrue($this->vcsAdapter->deleteWebhook(static::$owner, $repositoryName, $webhookId)); } finally { $this->discardRepositories($repositoryName); @@ -1725,9 +1722,7 @@ public function testWebhookPushEvent(): void */ public function testCreateAndDeleteWebhookWithoutDelivery(): void { - if (static::$supportsWebhookDelivery) { - $this->markTestSkipped('covered by testWebhookPushEvent()'); - } + $this->skipUnlessSupported(!static::$supportsWebhookDelivery, 'duplicating the webhook deletion testWebhookPushEvent() already covers'); $repositoryName = 'test-create-delete-webhook-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); From b53fa7e2c9f8cff90efb935338f8890d241d5e16 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 31 Jul 2026 16:02:40 +0530 Subject: [PATCH 18/34] fix: don't run webhook creation against a token that can't create one testCreateAndDeleteWebhookWithoutDelivery ran for GitHub because it shares GitHub's $supportsWebhookDelivery = false, but that flag is about delivery reachability, not whether creating a webhook works at all -- a GitHub App installation token gets 403 "Resource not accessible by integration" from the classic per-repo webhook endpoint, a permission limitation this library has apparently never had a test exercise before (testWebhookPushEvent/PullRequestEvent were already skipped for GitHub for the same pre-existing flag). Split the capability in two: $supportsWebhookDelivery (delivery reaches the test catcher) and the new $supportsWebhookCreation (creating a webhook through the API works at all). GitHub declares the latter false; Bitbucket keeps the default true, since it creates a webhook fine and only fails to have Bitbucket Cloud reach a local address. --- tests/VCS/Adapter/GitHubTest.php | 6 ++++++ tests/VCS/Base.php | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 17d57374..ee81b2e2 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -23,6 +23,12 @@ class GitHubTest extends Base protected static bool $supportsUserLookup = false; protected static bool $computesLanguagesAsynchronously = true; protected static bool $supportsWebhookDelivery = false; + + // A GitHub App installation token gets 403 "Resource not accessible by + // integration" from the classic per-repo webhook endpoint this library + // uses; only an OAuth token or PAT can manage it. + protected static bool $supportsWebhookCreation = false; + protected static bool $resolvesOwnerFromRepositoryId = false; protected static bool $rejectsInvalidRepositoryNames = false; diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 993c4b64..46b12828 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -125,6 +125,16 @@ abstract class Base extends TestCase protected static bool $supportsWebhookDelivery = true; + /** + * Whether creating a webhook through the API is possible at all, + * independent of whether delivery can reach the test catcher. A GitHub + * App installation token can't manage classic per-repo webhooks (403 + * "Resource not accessible by integration"), a different limitation from + * Bitbucket's -- Bitbucket creates one fine, it just can't deliver to a + * local address. + */ + protected static bool $supportsWebhookCreation = true; + protected static bool $resolvesOwnerFromRepositoryId = true; protected static bool $rejectsInvalidRepositoryNames = true; @@ -1717,12 +1727,14 @@ public function testWebhookPushEvent(): void /** * Covers createWebhook()/deleteWebhook() through the API alone, for an * adapter whose webhooks testWebhookPushEvent() can't reach to also cover - * deletion there. Skipped otherwise so this doesn't create a second - * webhook alongside the one that test already creates and deletes. + * deletion there. Skipped when that test already covers it, and skipped + * separately when the adapter can't create a webhook through the API at + * all -- a different limitation from not being able to deliver one. */ public function testCreateAndDeleteWebhookWithoutDelivery(): void { $this->skipUnlessSupported(!static::$supportsWebhookDelivery, 'duplicating the webhook deletion testWebhookPushEvent() already covers'); + $this->skipUnlessSupported(static::$supportsWebhookCreation, 'creating a webhook through the API'); $repositoryName = 'test-create-delete-webhook-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); From 10199a7eb26433b34547027fb9763d4e7aaf19c0 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 31 Jul 2026 16:06:52 +0530 Subject: [PATCH 19/34] style: trim over-explained comments down to one line each --- src/VCS/Adapter/Git/Bitbucket.php | 4 +--- tests/VCS/Adapter/BitbucketTest.php | 11 +++-------- tests/VCS/Adapter/GitHubTest.php | 4 +--- tests/VCS/Base.php | 15 +++------------ 4 files changed, 8 insertions(+), 26 deletions(-) diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 870fee3b..a37877e1 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -1091,9 +1091,7 @@ public function getUser(string $username): array } /** - * Account the access token belongs to. Internal to this adapter -- used - * only by getOwnerName() below, the same shape Gitea's - * getAuthenticatedUserLogin() takes for the equivalent lookup. + * Account the access token belongs to. * * @return array */ diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index c042e97b..1e99ec0b 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -39,18 +39,13 @@ class BitbucketTest extends Base // reports the account the token belongs to protected static bool $resolvesOwnerFromRepositoryId = false; - // Repositories group into workspaces, which the shared namespace shape - // (personal vs group) doesn't fit -- Bitbucket has no personal-vs-team - // distinction to report, so this is left unsupported like GitHub and Gitea + // Workspaces have no personal-vs-team distinction to report as 'kind' protected static bool $supportsNamespaceListing = false; - // Accounts are looked up by uuid or Atlassian account id; only some - // resolve by the handle Base::testGetUser() would look up + // Accounts are looked up by uuid, not by handle protected static bool $supportsUserLookup = false; - // Bitbucket Cloud only delivers webhooks to publicly reachable urls, so it - // cannot reach the test catcher; testCreateAndDeleteWebhook in Base covers - // webhook creation and deletion through the API instead + // Bitbucket Cloud can't reach a local test catcher protected static bool $supportsWebhookDelivery = false; protected function signWebhookPayload(string $payload, string $secret): string diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index ee81b2e2..5da7f55c 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -24,9 +24,7 @@ class GitHubTest extends Base protected static bool $computesLanguagesAsynchronously = true; protected static bool $supportsWebhookDelivery = false; - // A GitHub App installation token gets 403 "Resource not accessible by - // integration" from the classic per-repo webhook endpoint this library - // uses; only an OAuth token or PAT can manage it. + // A GitHub App token gets 403 on the classic per-repo webhook endpoint protected static bool $supportsWebhookCreation = false; protected static bool $resolvesOwnerFromRepositoryId = false; diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 46b12828..2f68baf1 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -125,14 +125,8 @@ abstract class Base extends TestCase protected static bool $supportsWebhookDelivery = true; - /** - * Whether creating a webhook through the API is possible at all, - * independent of whether delivery can reach the test catcher. A GitHub - * App installation token can't manage classic per-repo webhooks (403 - * "Resource not accessible by integration"), a different limitation from - * Bitbucket's -- Bitbucket creates one fine, it just can't deliver to a - * local address. - */ + // Whether creating a webhook through the API works at all, separate from + // whether delivery can reach the test catcher. protected static bool $supportsWebhookCreation = true; protected static bool $resolvesOwnerFromRepositoryId = true; @@ -1726,10 +1720,7 @@ public function testWebhookPushEvent(): void /** * Covers createWebhook()/deleteWebhook() through the API alone, for an - * adapter whose webhooks testWebhookPushEvent() can't reach to also cover - * deletion there. Skipped when that test already covers it, and skipped - * separately when the adapter can't create a webhook through the API at - * all -- a different limitation from not being able to deliver one. + * adapter testWebhookPushEvent() skips. */ public function testCreateAndDeleteWebhookWithoutDelivery(): void { From 130ee1663782e523b20ff7e1acc149d681bad80b Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 31 Jul 2026 16:24:09 +0530 Subject: [PATCH 20/34] fix: move createWebhook/deleteWebhook onto Adapter, next to their scope Git is Adapter's only subclass -- every concrete adapter extends Git, so the split has no current purpose. It also already put the other webhook methods (getEventHeaderName, getSignatureHeaderName, getSupportedWebhookScopes) on Adapter while these two sat on Git alone. Moves both next to getSupportedWebhookScopes so every webhook-related abstract lives on the same class. No behavior change -- every adapter already implements both concretely. --- src/VCS/Adapter.php | 23 +++++++++++++++++++++++ src/VCS/Adapter/Git.php | 23 ----------------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index f406ad18..ba27db85 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -271,6 +271,29 @@ abstract public function getSignatureHeaderName(): string; */ abstract public function getSupportedWebhookScopes(): array; + /** + * Create a webhook on a repository + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $url Webhook URL to send events to + * @param string $secret Webhook secret for signature validation + * @param array $events Events to trigger the webhook + * @return int|string Webhook ID, as the provider identifies it: an int on + * the providers that number their hooks, a string where + * they don't (Bitbucket identifies them by UUID) + */ + abstract public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int|string; + + /** + * Delete a webhook from a repository. + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param int|string $webhookId Webhook ID as returned by createWebhook() + */ + abstract public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool; + /** * Browser-facing URL for a repository's home page. */ diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index 849ba6c8..cd8e0ad9 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -72,29 +72,6 @@ abstract public function createBranch(string $owner, string $repositoryName, str */ abstract public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array; - /** - * Create a webhook on a repository - * - * @param string $owner Owner of the repository - * @param string $repositoryName Name of the repository - * @param string $url Webhook URL to send events to - * @param string $secret Webhook secret for signature validation - * @param array $events Events to trigger the webhook - * @return int|string Webhook ID, as the provider identifies it: an int on - * the providers that number their hooks, a string where - * they don't (Bitbucket identifies them by UUID) - */ - abstract public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int|string; - - /** - * Delete a webhook from a repository. - * - * @param string $owner Owner of the repository - * @param string $repositoryName Name of the repository - * @param int|string $webhookId Webhook ID as returned by createWebhook() - */ - abstract public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool; - /** * Create a tag in a repository * From 3dde3de01db00866f193da7b6bf0a7a192046710 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 31 Jul 2026 16:52:57 +0530 Subject: [PATCH 21/34] fix: don't orphan a webhook when its create response lacks a uuid createWebhook() threw once Bitbucket confirmed creation without a uuid in the body, but by then the hook was already live and delivering events with no id left to call deleteWebhook() with -- an unrecoverable leak. Falls back to listing the repository's webhooks and matching by url to recover the uuid before giving up. --- src/VCS/Adapter/Git/Bitbucket.php | 40 ++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index a37877e1..c7d7be16 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -1011,12 +1011,50 @@ public function createWebhook(string $owner, string $repositoryName, string $url $uuid = $response['body']['uuid'] ?? null; if ($uuid === null || $uuid === '') { - throw new Exception('Webhook created but response did not include a uuid'); + // The hook is already live on Bitbucket even without a uuid in this + // response, so find it by url instead of leaving it running with no + // way to delete it. + $uuid = $this->findWebhookUuid($owner, $repositoryName, $url); + } + + if (empty($uuid)) { + throw new Exception('Webhook created but its uuid could not be resolved'); } return (string) $uuid; } + /** + * Finds the uuid of a repository's webhook by the url it delivers to. + */ + private function findWebhookUuid(string $owner, string $repositoryName, string $url): ?string + { + $page = 1; + do { + $apiUrl = "/repositories/{$owner}/{$repositoryName}/hooks?pagelen=" . self::PAGE_SIZE . "&page={$page}"; + + $response = $this->call(self::METHOD_GET, $apiUrl, ['Authorization' => 'Bearer ' . $this->accessToken]); + + $responseHeaders = $response['headers'] ?? []; + if (($responseHeaders['status-code'] ?? 0) >= 400) { + return null; + } + + $responseBody = $response['body'] ?? []; + $values = is_array($responseBody) ? ($responseBody['values'] ?? []) : []; + + foreach ($values as $hook) { + if (is_array($hook) && ($hook['url'] ?? null) === $url) { + return (string) ($hook['uuid'] ?? '') ?: null; + } + } + + $page++; + } while (!empty($responseBody['next'])); + + return null; + } + /** * Delete a webhook from a repository. */ From 987f5920fa630da8d240ec253ca306caeb8df74e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 31 Jul 2026 16:58:19 +0530 Subject: [PATCH 22/34] fix: pick the newest webhook by created_at, dedupe repeated logic findWebhookUuid()'s url match could return an older webhook sharing the same delivery url instead of the one just created, silently handing the caller the wrong uuid to manage. Picks the match with the latest created_at instead of the first one found. Also extracted two blocks duplicated verbatim elsewhere in this file: the x-token-auth credential embedding shared by getRepositoryPresignedUrl() and generateCloneCommand(), and the linked-vs-raw author name resolution shared by parseCommit() and parsePushChange(). --- src/VCS/Adapter/Git/Bitbucket.php | 78 ++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 26 deletions(-) diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index c7d7be16..6b2ac574 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -687,6 +687,23 @@ public function getLatestCommit(string $owner, string $repositoryName, string $b return $this->parseCommit($values[0]); } + /** + * Bitbucket only names an unlinked author in a raw "Name " string; + * an author linked to an account is named by the account instead. + * + * @param array $author + */ + private function authorNameOf(array $author): string + { + $user = is_array($author['user'] ?? null) ? $author['user'] : []; + $name = $user['display_name'] ?? ''; + if (!empty($name)) { + return (string) $name; + } + + return \trim(\preg_replace('/<[^>]*>/', '', (string) ($author['raw'] ?? '')) ?? ''); + } + /** * Normalize a Bitbucket commit into the shape every adapter reports. * @@ -701,11 +718,7 @@ private function parseCommit(array $commit): array $user = is_array($user) ? $user : []; $userLinks = is_array($user['links'] ?? null) ? $user['links'] : []; - // Unlinked authors are only described by a raw "Name " string. - $name = $user['display_name'] ?? ''; - if (empty($name)) { - $name = \trim(\preg_replace('/<[^>]*>/', '', (string) ($author['raw'] ?? '')) ?? ''); - } + $name = $this->authorNameOf($author); return [ 'commitAuthor' => empty($name) ? 'Unknown' : $name, @@ -1025,10 +1038,16 @@ public function createWebhook(string $owner, string $repositoryName, string $url } /** - * Finds the uuid of a repository's webhook by the url it delivers to. + * Finds the uuid of a repository's webhook by the url it delivers to. An + * older webhook can already share the url, so among every match this + * returns the one with the most recent created_at, the one this call just + * created. */ private function findWebhookUuid(string $owner, string $repositoryName, string $url): ?string { + $newestUuid = null; + $newestCreatedAt = ''; + $page = 1; do { $apiUrl = "/repositories/{$owner}/{$repositoryName}/hooks?pagelen=" . self::PAGE_SIZE . "&page={$page}"; @@ -1037,22 +1056,28 @@ private function findWebhookUuid(string $owner, string $repositoryName, string $ $responseHeaders = $response['headers'] ?? []; if (($responseHeaders['status-code'] ?? 0) >= 400) { - return null; + return $newestUuid; } $responseBody = $response['body'] ?? []; $values = is_array($responseBody) ? ($responseBody['values'] ?? []) : []; foreach ($values as $hook) { - if (is_array($hook) && ($hook['url'] ?? null) === $url) { - return (string) ($hook['uuid'] ?? '') ?: null; + if (!is_array($hook) || ($hook['url'] ?? null) !== $url) { + continue; + } + + $createdAt = (string) ($hook['created_at'] ?? ''); + if ($newestUuid === null || $createdAt > $newestCreatedAt) { + $newestUuid = (string) ($hook['uuid'] ?? '') ?: null; + $newestCreatedAt = $createdAt; } } $page++; } while (!empty($responseBody['next'])); - return null; + return $newestUuid; } /** @@ -1188,6 +1213,20 @@ public function getOwnerName(string $installationId, ?int $repositoryId = null): return (string) ($user['username'] ?? ($user['nickname'] ?? '')); } + /** + * $bitbucketUrl with the access token embedded as HTTP Basic userinfo + * (https://x-token-auth:{token}@bitbucket.org), the scheme both + * getRepositoryPresignedUrl() and generateCloneCommand() authenticate with. + */ + private function authenticatedBitbucketUrl(): string + { + if (empty($this->accessToken)) { + return $this->bitbucketUrl; + } + + return str_replace('://', '://x-token-auth:' . urlencode($this->accessToken) . '@', $this->bitbucketUrl); + } + /** * @link https://support.atlassian.com/bitbucket-cloud/kb/how-to-download-repositories-using-the-api/ * @@ -1199,7 +1238,6 @@ public function getOwnerName(string $installationId, ?int $repositoryId = null): * It travels as HTTP Basic userinfo rather than the query parameter those * two use: Bitbucket's documented form for this endpoint is basic auth, * and its `?access_token=` query parameter was removed in CHANGE-3052. - * `x-token-auth` is the same scheme generateCloneCommand() below relies on. */ public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string { @@ -1209,10 +1247,7 @@ public function getRepositoryPresignedUrl(string $owner, string $repositoryName, default => throw new Exception("Invalid archive format: {$format}. Use 'tarball' or 'zipball'."), }; - $baseUrl = $this->bitbucketUrl; - if (!empty($this->accessToken)) { - $baseUrl = str_replace('://', '://x-token-auth:' . urlencode($this->accessToken) . '@', $this->bitbucketUrl); - } + $baseUrl = $this->authenticatedBitbucketUrl(); // Bitbucket resolves HEAD to the repository's default branch $ref = empty($ref) ? 'HEAD' : $ref; @@ -1229,13 +1264,7 @@ public function generateCloneCommand(string $owner, string $repositoryName, stri $rootDirectory = '*'; } - // Bitbucket clone URL format: https://x-token-auth:{token}@host/owner/repo.git - $baseUrl = $this->bitbucketUrl; - if (!empty($this->accessToken)) { - $baseUrl = str_replace('://', '://x-token-auth:' . urlencode($this->accessToken) . '@', $this->bitbucketUrl); - } - - $cloneUrl = escapeshellarg("{$baseUrl}/{$owner}/{$repositoryName}.git"); + $cloneUrl = escapeshellarg("{$this->authenticatedBitbucketUrl()}/{$owner}/{$repositoryName}.git"); $directory = escapeshellarg($directory); $rootDirectory = escapeshellarg($rootDirectory); @@ -1394,10 +1423,7 @@ private function parsePushChange(array $change, array $repository, array $actor) $author = is_array($target['author'] ?? null) ? $target['author'] : []; $raw = (string) ($author['raw'] ?? ''); - $authorName = $author['user']['display_name'] ?? ''; - if (empty($authorName)) { - $authorName = \trim(\preg_replace('/<[^>]*>/', '', $raw) ?? ''); - } + $authorName = $this->authorNameOf($author); $authorEmail = ''; if (\preg_match('/<([^>]*)>/', $raw, $matches) === 1) { From f973c5be0045ac580756f5adfeb8b611cc874fe6 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 31 Jul 2026 17:03:43 +0530 Subject: [PATCH 23/34] fix: refuse to guess between webhooks sharing a url created_at can tie or race with a concurrent creation, so picking the "newest" match was still a guess -- and a wrong guess is worse than the original problem: the caller would later delete an unrelated webhook while the one it actually just created stays orphaned and unmanaged. findSingleWebhookByUrl() only returns a uuid when exactly one webhook has that url; any ambiguity or none at all now surfaces a clear error naming the repository and url to check manually, rather than silently resolving to a webhook that might not be the right one. --- src/VCS/Adapter/Git/Bitbucket.php | 36 +++++++++++++------------------ 1 file changed, 15 insertions(+), 21 deletions(-) diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 6b2ac574..7c63e14d 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -1025,28 +1025,28 @@ public function createWebhook(string $owner, string $repositoryName, string $url $uuid = $response['body']['uuid'] ?? null; if ($uuid === null || $uuid === '') { // The hook is already live on Bitbucket even without a uuid in this - // response, so find it by url instead of leaving it running with no - // way to delete it. - $uuid = $this->findWebhookUuid($owner, $repositoryName, $url); + // response. Recovering it by listing and matching on url is only + // safe when exactly one hook has that url -- guessing between + // several would risk the caller later deleting an unrelated hook + // while the real one it just created stays orphaned. + $uuid = $this->findSingleWebhookByUrl($owner, $repositoryName, $url); } if (empty($uuid)) { - throw new Exception('Webhook created but its uuid could not be resolved'); + throw new Exception("Webhook created but its uuid could not be safely resolved; check {$owner}/{$repositoryName}'s webhooks at {$url} manually"); } return (string) $uuid; } /** - * Finds the uuid of a repository's webhook by the url it delivers to. An - * older webhook can already share the url, so among every match this - * returns the one with the most recent created_at, the one this call just - * created. + * Uuid of the one repository webhook delivering to this url, or null if + * there is none or more than one -- callers can't tell which of several + * matches is theirs, so this refuses to guess. */ - private function findWebhookUuid(string $owner, string $repositoryName, string $url): ?string + private function findSingleWebhookByUrl(string $owner, string $repositoryName, string $url): ?string { - $newestUuid = null; - $newestCreatedAt = ''; + $matches = []; $page = 1; do { @@ -1056,28 +1056,22 @@ private function findWebhookUuid(string $owner, string $repositoryName, string $ $responseHeaders = $response['headers'] ?? []; if (($responseHeaders['status-code'] ?? 0) >= 400) { - return $newestUuid; + break; } $responseBody = $response['body'] ?? []; $values = is_array($responseBody) ? ($responseBody['values'] ?? []) : []; foreach ($values as $hook) { - if (!is_array($hook) || ($hook['url'] ?? null) !== $url) { - continue; - } - - $createdAt = (string) ($hook['created_at'] ?? ''); - if ($newestUuid === null || $createdAt > $newestCreatedAt) { - $newestUuid = (string) ($hook['uuid'] ?? '') ?: null; - $newestCreatedAt = $createdAt; + if (is_array($hook) && ($hook['url'] ?? null) === $url) { + $matches[] = (string) ($hook['uuid'] ?? ''); } } $page++; } while (!empty($responseBody['next'])); - return $newestUuid; + return count($matches) === 1 ? $matches[0] : null; } /** From 7d484e1442b94cc800f802aa219527ce7811deb9 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 31 Jul 2026 17:37:02 +0530 Subject: [PATCH 24/34] refactor: remove duplication, dead code and over-explaining from the adapter An exhaustive pass over the diff turned up more of what earlier rounds already fixed piecemeal: Duplication: - resolveRef() and resolveDefaultBranch() repeated the same mainbranch lookup, differing only in the empty case -> mainBranchName(). - listSource() and getRepositoryContent() repeated the resolveRef/ normalize/build-/src/-url preamble verbatim -> sourceUrl(). - normalizeRepository() and getEventRepositoryOwner() both derived the workspace slug with a full_name fallback -> workspaceSlugOf(). - searchRepositories() re-implemented the id/private/pushed_at mapping normalizeRepository() exists to centralize; it now runs through it. - getRepositoryName() re-issued the request getRepository() already makes; it now splits the "workspace/slug" id and delegates. - The "no numeric repository ids" note was stated six times; it is now stated once, in normalizeRepository() where the id is minted. Dead code: - setBitbucketUrl() had no caller anywhere. - $refreshToken was assigned and never read. - getEventRepositoryId() wrapped a single strval(); inlined. - Base's $supportsPresignedUrls was never set false by any adapter, so both branches it guarded were unreachable. - BitbucketTest read TESTS_BITBUCKET_ENDPOINT, which no compose file, workflow or doc sets. Consistency with the sibling adapters: - $body -> $responseBody, $SHA -> $commitHash. - listRepositoryLanguages() returns [] for a missing repository rather than throwing, matching the other list-style getters. - Dropped defensive is_array() ternaries where plain ?? [] is what GitHub/GitLab/Gitea write, and docblocks that restated the method name or the contract in Adapter.php. - The inverted skipUnlessSupported(!$flag, ...) in Base now reads as a plain markTestSkipped with a message that says what is happening. --- src/VCS/Adapter/Git/Bitbucket.php | 288 ++++++++++++---------------- tests/VCS/Adapter/BitbucketTest.php | 13 +- tests/VCS/Base.php | 16 +- 3 files changed, 127 insertions(+), 190 deletions(-) diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 7c63e14d..1eb3b90a 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -25,27 +25,6 @@ class Bitbucket extends Git */ private const MAX_TREE_DEPTH = 100; - protected string $endpoint = 'https://api.bitbucket.org/2.0'; - - /** - * Browser-facing host. Bitbucket Cloud serves its API from a separate - * host, so unlike the self-hosted adapters this is tracked on its own. - */ - protected string $bitbucketUrl = 'https://bitbucket.org'; - - protected string $accessToken; - - protected ?string $refreshToken = null; - - protected Cache $cache; - - /** - * Global Headers - * - * @var array - */ - protected $headers = ['content-type' => 'application/json']; - /** * Maps the state vocabulary shared by the other adapters (GitHub's) onto * Bitbucket's build states. @@ -81,30 +60,37 @@ class Bitbucket extends Git 'pullrequest:rejected' => 'closed', ]; + protected string $endpoint = 'https://api.bitbucket.org/2.0'; + + /** + * Browser-facing host; the API lives on a separate host. + */ + protected string $bitbucketUrl = 'https://bitbucket.org'; + + protected string $accessToken; + + protected Cache $cache; + + /** + * Global Headers + * + * @var array + */ + protected $headers = ['content-type' => 'application/json']; + public function __construct(Cache $cache) { $this->cache = $cache; } /** - * Point the adapter at a different API base, e.g. a proxy in front of - * Bitbucket Cloud. Expects a full base URL such as - * 'https://api.bitbucket.org/2.0'. + * Moves only the API host; the browser-facing host stays on bitbucket.org. */ public function setEndpoint(string $endpoint): void { $this->endpoint = rtrim($endpoint, '/'); } - /** - * Point the adapter at a different browser-facing host, e.g. - * 'https://bitbucket.org'. - */ - public function setBitbucketUrl(string $bitbucketUrl): void - { - $this->bitbucketUrl = rtrim($bitbucketUrl, '/'); - } - public function getName(): string { return 'bitbucket'; @@ -154,7 +140,6 @@ public function initializeVariables(string $installationId, string $privateKey, { if (!empty($accessToken)) { $this->accessToken = $accessToken; - $this->refreshToken = $refreshToken; return; } @@ -180,6 +165,17 @@ private function encodeRepositoryPath(string $path): string return implode('/', $segments); } + /** + * Name of the repository's main branch, or '' when it has none yet (an + * empty repository names its main branch only once the root commit lands). + */ + private function mainBranchName(string $owner, string $repositoryName): string + { + $mainbranch = $this->getRepository($owner, $repositoryName)['mainbranch'] ?? []; + + return (string) ($mainbranch['name'] ?? ''); + } + /** * Bitbucket's source endpoints always want an explicit ref, so fall back to * the repository's main branch when the caller didn't name one. @@ -190,37 +186,51 @@ private function resolveRef(string $owner, string $repositoryName, string $ref): return $ref; } - $repository = $this->getRepository($owner, $repositoryName); - $mainbranch = $repository['mainbranch'] ?? []; - $name = is_array($mainbranch) ? ($mainbranch['name'] ?? '') : ''; - + $name = $this->mainBranchName($owner, $repositoryName); if (empty($name)) { throw new Exception("Unable to resolve the main branch of {$owner}/{$repositoryName}."); } - return (string) $name; + return $name; + } + + /** + * Slug of the workspace holding a repository, read off `workspace.slug` or + * the first segment of `full_name` when the workspace object is absent. + * + * @param array $repository + */ + private function workspaceSlugOf(array $repository): string + { + $slug = (string) ($repository['workspace']['slug'] ?? ''); + if (!empty($slug)) { + return $slug; + } + + $fullName = (string) ($repository['full_name'] ?? ''); + + return strpos($fullName, '/') !== false ? explode('/', $fullName)[0] : ''; } /** * Repository responses carry Bitbucket's own field names; surface the keys * the other adapters report under so consumers can treat them alike. * + * Bitbucket has no numeric repository ids; "workspace/slug" is the + * identifier its API routes on, so that is what `id` carries and what + * getRepositoryName() and event payloads report back. + * * @param array $repository * @return array */ private function normalizeRepository(array $repository): array { - $fullName = (string) ($repository['full_name'] ?? ''); - - // Bitbucket has no numeric repository ids; "workspace/slug" is the - // identifier its API routes on, so that is what getRepositoryName() - // and getOwnerName() expect to receive back. - $repository['id'] = $fullName; + $repository['id'] = (string) ($repository['full_name'] ?? ''); $repository['private'] = ($repository['is_private'] ?? false) === true; $repository['pushed_at'] = $repository['updated_on'] ?? ''; - if (empty($repository['workspace']['slug']) && strpos($fullName, '/') !== false) { - $repository['workspace'] = ['slug' => explode('/', $fullName)[0]]; + if (empty($repository['workspace']['slug'])) { + $repository['workspace'] = ['slug' => $this->workspaceSlugOf($repository)]; } return $repository; @@ -242,9 +252,9 @@ public function createRepository(string $owner, string $repositoryName, bool $pr throw new Exception("Creating repository {$repositoryName} failed with status code {$statusCode}", $statusCode); } - $body = $response['body'] ?? []; + $responseBody = $response['body'] ?? []; - return $this->normalizeRepository(is_array($body) ? $body : []); + return $this->normalizeRepository(is_array($responseBody) ? $responseBody : []); } public function deleteRepository(string $owner, string $repositoryName): bool @@ -274,28 +284,21 @@ public function getRepository(string $owner, string $repositoryName): array throw new RepositoryNotFound("Repository not found"); } - $body = $response['body'] ?? []; + $responseBody = $response['body'] ?? []; - return $this->normalizeRepository(is_array($body) ? $body : []); + return $this->normalizeRepository(is_array($responseBody) ? $responseBody : []); } public function getRepositoryName(string $repositoryId): string { - // Bitbucket has no numeric repository ids, so $repositoryId is the - // "workspace/slug" pair reported as `id` by createRepository(). - $url = "/repositories/{$repositoryId}"; - - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); - - $responseHeaders = $response['headers'] ?? []; - $statusCode = $responseHeaders['status-code'] ?? 0; - if ($statusCode >= 400) { + // $repositoryId is the "workspace/slug" id normalizeRepository() mints + if (strpos($repositoryId, '/') === false) { throw new RepositoryNotFound("Repository {$repositoryId} not found"); } - $responseBody = $response['body'] ?? []; + [$workspace, $slug] = explode('/', $repositoryId, 2); - return $responseBody['name'] ?? ''; + return (string) ($this->getRepository($workspace, $slug)['name'] ?? ''); } public function hasAccessToAllRepositories(): bool @@ -334,12 +337,13 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri $repositories = []; foreach (($responseBody['values'] ?? []) as $repository) { + $repository = $this->normalizeRepository(is_array($repository) ? $repository : []); $repositories[] = [ - 'id' => $repository['full_name'] ?? '', + 'id' => $repository['id'], 'name' => $repository['name'] ?? '', 'description' => $repository['description'] ?? '', - 'private' => ($repository['is_private'] ?? false) === true, - 'pushed_at' => $repository['updated_on'] ?? '', + 'private' => $repository['private'], + 'pushed_at' => $repository['pushed_at'], ]; } @@ -377,6 +381,18 @@ public function listRepositoryContents(string $owner, string $repositoryName, st return $contents; } + /** + * URL of a path in the repository's source tree at a ref, resolving the + * ref to the main branch when empty. Throws when that resolution fails. + */ + private function sourceUrl(string $owner, string $repositoryName, string $path, string $ref): string + { + $ref = $this->resolveRef($owner, $repositoryName, $ref); + $path = $this->normalizeRepositoryPath($path); + + return "/repositories/{$owner}/{$repositoryName}/src/" . rawurlencode($ref) . '/' . $this->encodeRepositoryPath($path); + } + /** * List entries of a directory in the repository, following pagination. * Returns an empty list when the ref or path doesn't exist. @@ -387,14 +403,11 @@ public function listRepositoryContents(string $owner, string $repositoryName, st private function listSource(string $owner, string $repositoryName, string $path, string $ref, string $suffix = ''): array { try { - $ref = $this->resolveRef($owner, $repositoryName, $ref); + $base = $this->sourceUrl($owner, $repositoryName, $path, $ref); } catch (Exception $e) { return []; } - $path = $this->normalizeRepositoryPath($path); - $base = "/repositories/{$owner}/{$repositoryName}/src/" . rawurlencode($ref) . '/' . $this->encodeRepositoryPath($path); - $items = []; $page = 1; do { @@ -428,14 +441,11 @@ private function listSource(string $owner, string $repositoryName, string $path, public function getRepositoryContent(string $owner, string $repositoryName, string $path, string $ref = ''): array { try { - $ref = $this->resolveRef($owner, $repositoryName, $ref); + $url = $this->sourceUrl($owner, $repositoryName, $path, $ref); } catch (Exception $e) { throw new FileNotFound(); } - $path = $this->normalizeRepositoryPath($path); - $url = "/repositories/{$owner}/{$repositoryName}/src/" . rawurlencode($ref) . '/' . $this->encodeRepositoryPath($path); - // A missing file is the expected, common case here (not every repo // has e.g. package.json) -- call() throws on a non-JSON/empty body, // which a 404 can legitimately have, so that has to be caught here @@ -489,7 +499,11 @@ public function getRepositoryContent(string $owner, string $repositoryName, stri */ public function listRepositoryLanguages(string $owner, string $repositoryName): array { - $repository = $this->getRepository($owner, $repositoryName); + try { + $repository = $this->getRepository($owner, $repositoryName); + } catch (RepositoryNotFound $e) { + return []; + } $language = (string) ($repository['language'] ?? ''); @@ -545,16 +559,12 @@ public function createFile(string $owner, string $repositoryName, string $filepa /** * Name of the branch a commit lands on when the caller didn't pick one. - * An empty repository has no main branch yet, and Bitbucket names the - * branch of its root commit after whatever we ask for, so default to 'main'. + * Bitbucket names an empty repository's branch after whatever the first + * commit asks for, so default to 'main'. */ private function resolveDefaultBranch(string $owner, string $repositoryName): string { - $repository = $this->getRepository($owner, $repositoryName); - $mainbranch = $repository['mainbranch'] ?? []; - $name = is_array($mainbranch) ? ($mainbranch['name'] ?? '') : ''; - - return empty($name) ? 'main' : (string) $name; + return $this->mainBranchName($owner, $repositoryName) ?: 'main'; } public function createBranch(string $owner, string $repositoryName, string $newBranchName, string $oldBranchName): array @@ -730,9 +740,9 @@ private function parseCommit(array $commit): array ]; } - public function updateCommitStatus(string $repositoryName, string $SHA, string $owner, string $state, string $description = '', string $target_url = '', string $context = ''): void + public function updateCommitStatus(string $repositoryName, string $commitHash, string $owner, string $state, string $description = '', string $target_url = '', string $context = ''): void { - $url = "/repositories/{$owner}/{$repositoryName}/commit/" . rawurlencode($SHA) . '/statuses/build'; + $url = "/repositories/{$owner}/{$repositoryName}/commit/" . rawurlencode($commitHash) . '/statuses/build'; // Bitbucket identifies a status by its key and overwrites a status // posted under a key it already has, so the context doubles as the key. @@ -744,7 +754,7 @@ public function updateCommitStatus(string $repositoryName, string $SHA, string $ 'state' => self::COMMIT_STATE_MAP[$state] ?? $state, // A build status without a URL is rejected, so point at the commit // itself when the caller has nowhere better to link. - 'url' => empty($target_url) ? $this->getCommitUrl($owner, $repositoryName, $SHA) : $target_url, + 'url' => empty($target_url) ? $this->getCommitUrl($owner, $repositoryName, $commitHash) : $target_url, ]; if (!empty($description)) { @@ -991,10 +1001,6 @@ public function updateComment(string $owner, string $repositoryName, string $com } /** - * Create a webhook on a repository. Bitbucket identifies hooks by UUID - * rather than by number, so this returns the UUID that deleteWebhook() - * takes. - * * @param array $events Event names, either this library's ('push', * 'pull_request') or Bitbucket's own keys * (e.g. 'repo:push') @@ -1024,11 +1030,8 @@ public function createWebhook(string $owner, string $repositoryName, string $url $uuid = $response['body']['uuid'] ?? null; if ($uuid === null || $uuid === '') { - // The hook is already live on Bitbucket even without a uuid in this - // response. Recovering it by listing and matching on url is only - // safe when exactly one hook has that url -- guessing between - // several would risk the caller later deleting an unrelated hook - // while the real one it just created stays orphaned. + // The hook is live even without a uuid in the response; recover it + // from the hook list rather than leaving it undeletable. $uuid = $this->findSingleWebhookByUrl($owner, $repositoryName, $url); } @@ -1074,9 +1077,6 @@ private function findSingleWebhookByUrl(string $owner, string $repositoryName, s return count($matches) === 1 ? $matches[0] : null; } - /** - * Delete a webhook from a repository. - */ public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool { $url = "/repositories/{$owner}/{$repositoryName}/hooks/" . rawurlencode((string) $webhookId); @@ -1134,17 +1134,17 @@ public function getUser(string $username): array throw new Exception("Failed to get user: HTTP {$statusCode}", $statusCode); } - $body = $response['body'] ?? []; - if (!is_array($body) || empty($body['uuid'])) { + $responseBody = $response['body'] ?? []; + if (!is_array($responseBody) || empty($responseBody['uuid'])) { throw new Exception("User not found: {$username}"); } // Bitbucket has no numeric user ids, and reports the handle as // `nickname`; surface both under the shared keys. - $body['id'] = $body['uuid']; - $body['username'] = $body['username'] ?? ($body['nickname'] ?? ''); + $responseBody['id'] = $responseBody['uuid']; + $responseBody['username'] = $responseBody['username'] ?? ($responseBody['nickname'] ?? ''); - return $body; + return $responseBody; } /** @@ -1162,26 +1162,16 @@ protected function getAuthenticatedUser(): array throw new Exception("Failed to get current user: HTTP {$statusCode}", $statusCode); } - $body = $response['body'] ?? []; + $responseBody = $response['body'] ?? []; - return is_array($body) ? $body : []; + return is_array($responseBody) ? $responseBody : []; } /** - * Bitbucket has no installations and no numeric repository ids, so - * $installationId and $repositoryId are both unused: the owner is always - * the workspace of the account the token belongs to. - * - * `/user`'s `username` (old accounts) or `nickname` (accounts migrated to - * Atlassian's unified identity) are display handles, not workspace - * identifiers -- for migrated accounts `nickname` is an opaque value - * Bitbucket's repository API doesn't recognize as a workspace, silently - * returning zero repositories rather than an error. The account's own - * UUID doesn't double as its workspace's UUID either -- confirmed live, - * the two are unrelated. The workspace is instead resolved via - * `/user/workspaces`, the endpoint Atlassian's migration guidance names - * as the user-scoped replacement for the cross-workspace `/workspaces` - * listing CHANGE-2770 removed. + * $installationId and $repositoryId are unused (Bitbucket has neither + * installations nor numeric repository ids): the owner is the token + * account's workspace, resolved via /user/workspaces because `/user`'s + * username/nickname are display handles, not workspace identifiers. */ public function getOwnerName(string $installationId, ?int $repositoryId = null): string { @@ -1190,13 +1180,9 @@ public function getOwnerName(string $installationId, ?int $repositoryId = null): $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; if ($statusCode < 400) { - $body = $response['body'] ?? []; - $values = is_array($body) ? ($body['values'] ?? []) : []; - $first = $values[0] ?? []; - // Some Bitbucket user-scoped list endpoints wrap the resource - // under its own key (e.g. the older /permissions/workspaces - // did); accept either shape rather than assume this one is flat. - $slug = $first['slug'] ?? ($first['workspace']['slug'] ?? ''); + $responseBody = $response['body'] ?? []; + $values = is_array($responseBody) ? ($responseBody['values'] ?? []) : []; + $slug = $values[0]['slug'] ?? ''; if (!empty($slug)) { return (string) $slug; } @@ -1209,8 +1195,7 @@ public function getOwnerName(string $installationId, ?int $repositoryId = null): /** * $bitbucketUrl with the access token embedded as HTTP Basic userinfo - * (https://x-token-auth:{token}@bitbucket.org), the scheme both - * getRepositoryPresignedUrl() and generateCloneCommand() authenticate with. + * (https://x-token-auth:{token}@bitbucket.org). */ private function authenticatedBitbucketUrl(): string { @@ -1224,14 +1209,9 @@ private function authenticatedBitbucketUrl(): string /** * @link https://support.atlassian.com/bitbucket-cloud/kb/how-to-download-repositories-using-the-api/ * - * Bitbucket serves this from the browser host rather than the API host, - * and answers it directly instead of redirecting to a signed URL, so -- - * unlike GitHub, which returns the redirect target -- the credential has - * to travel in the URL, as it does for GitLab and Gitea. - * - * It travels as HTTP Basic userinfo rather than the query parameter those - * two use: Bitbucket's documented form for this endpoint is basic auth, - * and its `?access_token=` query parameter was removed in CHANGE-3052. + * Bitbucket answers this directly instead of redirecting to a signed URL, + * so the access token is embedded as HTTP Basic userinfo -- the returned + * URL carries the credential and must be treated as a secret. */ public function getRepositoryPresignedUrl(string $owner, string $repositoryName, string $ref = '', string $format = 'tarball'): string { @@ -1323,8 +1303,7 @@ public function getEvents(string $event, string $payload): array switch ($event) { case 'repo:push': - $push = is_array($payloadArray['push'] ?? null) ? $payloadArray['push'] : []; - $changes = is_array($push['changes'] ?? null) ? $push['changes'] : []; + $changes = $payloadArray['push']['changes'] ?? []; $events = []; foreach ($changes as $change) { @@ -1357,44 +1336,21 @@ public function getEvents(string $event, string $payload): array */ private function isBranchChange(array $change): bool { - $new = is_array($change['new'] ?? null) ? $change['new'] : []; - $old = is_array($change['old'] ?? null) ? $change['old'] : []; - - $type = $new['type'] ?? ($old['type'] ?? 'branch'); + $type = $change['new']['type'] ?? ($change['old']['type'] ?? 'branch'); return \in_array($type, ['branch', 'named_branch'], true); } - /** - * Identifier of the repository a webhook payload describes. Bitbucket's - * repository UUID isn't routable on its own, so this reports the - * "workspace/slug" pair getRepositoryName() resolves, matching the `id` - * createRepository() and getRepository() report. - * - * @param array $repository - */ - private function getEventRepositoryId(array $repository): string - { - return strval($repository['full_name'] ?? ''); - } - /** * @param array $repository * @return array{owner: string, url: string} */ private function getEventRepositoryOwner(array $repository): array { - $url = (string) ($repository['links']['html']['href'] ?? ''); - - $workspace = is_array($repository['workspace'] ?? null) ? $repository['workspace'] : []; - $owner = (string) ($workspace['slug'] ?? ''); - - $fullName = (string) ($repository['full_name'] ?? ''); - if (empty($owner) && strpos($fullName, '/') !== false) { - $owner = explode('/', $fullName)[0]; - } - - return ['owner' => $owner, 'url' => $url]; + return [ + 'owner' => $this->workspaceSlugOf($repository), + 'url' => (string) ($repository['links']['html']['href'] ?? ''), + ]; } /** @@ -1429,7 +1385,7 @@ private function parsePushChange(array $change, array $repository, array $actor) 'branchDeleted' => ($change['closed'] ?? false) === true, 'branch' => $branch, 'branchUrl' => !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . '/branch/' . $branch : '', - 'repositoryId' => $this->getEventRepositoryId($repository), + 'repositoryId' => (string) ($repository['full_name'] ?? ''), 'repositoryName' => $repository['name'] ?? '', 'repositoryUrl' => $repositoryUrl, 'installationId' => '', // Bitbucket has no installations @@ -1479,7 +1435,7 @@ private function parsePullRequestEvent(string $event, array $payloadArray, array return [ 'branch' => $branch, 'branchUrl' => !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . '/branch/' . $branch : '', - 'repositoryId' => $this->getEventRepositoryId($repository), + 'repositoryId' => (string) ($repository['full_name'] ?? ''), 'repositoryName' => $repository['name'] ?? '', 'repositoryUrl' => $repositoryUrl, 'installationId' => '', diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 1e99ec0b..62e1716b 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -10,10 +10,7 @@ class BitbucketTest extends Base { - /** - * Bitbucket has no repository ids, so events report the "workspace/slug" - * pair its API routes on. - */ + // Bitbucket routes by "workspace/slug" rather than a numeric id protected const EVENT_REPOSITORY_ID = self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME; private const REPOSITORY_URL = 'https://bitbucket.org/' . self::EVENT_REPOSITORY_ID; @@ -72,11 +69,6 @@ protected function setupAdapter(): void refreshToken: '' ); - $endpoint = System::getEnv('TESTS_BITBUCKET_ENDPOINT') ?? ''; - if (!empty($endpoint)) { - $adapter->setEndpoint($endpoint); - } - if (empty(static::$owner)) { // Fall back to the token's own workspace when none is configured static::$owner = System::getEnv('TESTS_BITBUCKET_WORKSPACE') ?: $adapter->getOwnerName(''); @@ -86,9 +78,6 @@ protected function setupAdapter(): void } /** - * Bitbucket has no repository ids; createRepository() reports the - * "workspace/slug" pair its API routes on instead. - * * @param array $repository */ protected function repositoryIdOf(array $repository): string diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 2f68baf1..b220710f 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -137,8 +137,6 @@ abstract class Base extends TestCase protected static bool $supportsNamespaceListing = true; - protected static bool $supportsPresignedUrls = true; - /** * Whether a push event names the files it touched. Bitbucket's payload * carries no file lists at all. @@ -1600,13 +1598,6 @@ public function testValidateWebhookEvent(): void public function testGetRepositoryPresignedUrl(): void { - if (!static::$supportsPresignedUrls) { - $this->expectException(Exception::class); - $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, 'some-repo', static::$defaultBranch); - - return; - } - $repositoryName = 'test-presigned-url-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1630,8 +1621,6 @@ public function testGetRepositoryPresignedUrl(): void public function testGetRepositoryPresignedUrlWithInvalidFormat(): void { - $this->skipUnlessSupported(static::$supportsPresignedUrls, 'presigned archive urls'); - $this->expectException(Exception::class); $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, 'some-repo', static::$defaultBranch, 'invalid'); } @@ -1724,7 +1713,10 @@ public function testWebhookPushEvent(): void */ public function testCreateAndDeleteWebhookWithoutDelivery(): void { - $this->skipUnlessSupported(!static::$supportsWebhookDelivery, 'duplicating the webhook deletion testWebhookPushEvent() already covers'); + if (static::$supportsWebhookDelivery) { + $this->markTestSkipped('webhook deletion is already covered by testWebhookPushEvent()'); + } + $this->skipUnlessSupported(static::$supportsWebhookCreation, 'creating a webhook through the API'); $repositoryName = 'test-create-delete-webhook-' . \uniqid(); From 247a7221cc8b8e708a6c6dec0b56aa78bb13a32c Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Fri, 31 Jul 2026 18:07:12 +0530 Subject: [PATCH 25/34] fix: return the routable slug and a real blob sha; ride Base for status urls Two correctness bugs the shared suite can't currently catch, because the bitbucket CI job skips every test for want of credentials: - getRepositoryName() returned Bitbucket's free-form display `name`, not the `slug` its API routes on and every other method here takes as $repositoryName. GitLab has the same split and deliberately returns `path`; on GitHub and Gitea the two are the same value, so Bitbucket was the one adapter that picked the non-routable field. Any repository whose display name isn't already slug-form ("My Site" vs "my-site") would 404 clone, branch and commit-status calls downstream. Base can't see it: createRepository() posts name == slug, so the two never differ for anything the suite creates. - getRepositoryContent() reported the last-touching commit hash as `sha`, where Base::testGetRepositoryContentReportsBlobSha (ungated, shared) asserts it is the git blob id, and GitHub/GitLab/Gitea all return a real one. The adapter has the bytes, so it now computes the blob id git itself stores rather than substituting a different hash. Also: - Adapter::getEvents() default returned [[]] for an event the adapter doesn't report, where an overriding adapter returns []. It now drops the empty event so the two agree. - Base asserted a numeric webhook id in one of the three places it checks one, left over from before the contract widened to int|string. - Bitbucket's bespoke commit-status test re-ran Base::testGetCommitStatuses verbatim to change one assertion; Base now exposes an assertCommitStatusUrl() hook (no-op default) that BitbucketTest fills in, dropping a whole repository round trip. - Inlined the last single-use helpers (resolveRef and encodeRepositoryPath into sourceUrl, resolveDefaultBranch into createFile, getEventRepositoryOwner into its two callers). --- src/VCS/Adapter.php | 6 +- src/VCS/Adapter/Git/Bitbucket.php | 94 +++++++++-------------------- tests/VCS/Adapter/BitbucketTest.php | 52 +++------------- tests/VCS/Base.php | 20 +++++- 4 files changed, 62 insertions(+), 110 deletions(-) diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index ba27db85..310ac194 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -237,7 +237,11 @@ abstract public function getEvent(string $event, string $payload): array; */ public function getEvents(string $event, string $payload): array { - return [$this->getEvent($event, $payload)]; + $parsed = $this->getEvent($event, $payload); + + // An event the adapter doesn't report describes nothing, so report + // nothing rather than one empty event + return $parsed === [] ? [] : [$parsed]; } /** diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 1eb3b90a..10f12a53 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -155,16 +155,6 @@ protected function generateAccessToken(string $privateKey, string $appId): void return; } - /** - * Encode a repository path for use in a URL while keeping its separators. - */ - private function encodeRepositoryPath(string $path): string - { - $segments = array_map('rawurlencode', explode('/', $path)); - - return implode('/', $segments); - } - /** * Name of the repository's main branch, or '' when it has none yet (an * empty repository names its main branch only once the root commit lands). @@ -176,24 +166,6 @@ private function mainBranchName(string $owner, string $repositoryName): string return (string) ($mainbranch['name'] ?? ''); } - /** - * Bitbucket's source endpoints always want an explicit ref, so fall back to - * the repository's main branch when the caller didn't name one. - */ - private function resolveRef(string $owner, string $repositoryName, string $ref): string - { - if (!empty($ref)) { - return $ref; - } - - $name = $this->mainBranchName($owner, $repositoryName); - if (empty($name)) { - throw new Exception("Unable to resolve the main branch of {$owner}/{$repositoryName}."); - } - - return $name; - } - /** * Slug of the workspace holding a repository, read off `workspace.slug` or * the first segment of `full_name` when the workspace object is absent. @@ -298,7 +270,9 @@ public function getRepositoryName(string $repositoryId): string [$workspace, $slug] = explode('/', $repositoryId, 2); - return (string) ($this->getRepository($workspace, $slug)['name'] ?? ''); + // `slug` is the segment the API routes on; `name` is a display name + // that can differ from it, as GitLab's `path` does from its `name` + return (string) ($this->getRepository($workspace, $slug)['slug'] ?? $slug); } public function hasAccessToAllRepositories(): bool @@ -382,15 +356,24 @@ public function listRepositoryContents(string $owner, string $repositoryName, st } /** - * URL of a path in the repository's source tree at a ref, resolving the - * ref to the main branch when empty. Throws when that resolution fails. + * URL of a path in the repository's source tree. Bitbucket's source + * endpoints always want an explicit ref, so an empty one falls back to the + * main branch; throws when the repository has none yet. */ private function sourceUrl(string $owner, string $repositoryName, string $path, string $ref): string { - $ref = $this->resolveRef($owner, $repositoryName, $ref); - $path = $this->normalizeRepositoryPath($path); + if (empty($ref)) { + $ref = $this->mainBranchName($owner, $repositoryName); - return "/repositories/{$owner}/{$repositoryName}/src/" . rawurlencode($ref) . '/' . $this->encodeRepositoryPath($path); + if (empty($ref)) { + throw new Exception("Unable to resolve the main branch of {$owner}/{$repositoryName}."); + } + } + + // Encode each path segment but keep the separators between them + $path = implode('/', array_map('rawurlencode', explode('/', $this->normalizeRepositoryPath($path)))); + + return "/repositories/{$owner}/{$repositoryName}/src/" . rawurlencode($ref) . '/' . $path; } /** @@ -482,13 +465,12 @@ public function getRepositoryContent(string $owner, string $repositoryName, stri $content = $contentResponse['body'] ?? ''; $content = is_string($content) ? $content : ''; - $commit = $meta['commit'] ?? []; - return [ - // Bitbucket exposes no blob id, so report the commit the file was - // last changed in — the closest stable identifier it gives us. - 'sha' => is_array($commit) ? ($commit['hash'] ?? '') : '', - 'size' => $meta['size'] ?? \strlen($content), + // Bitbucket exposes no blob id, so compute the one git stores -- + // the repository is git backed, so this is the same value the + // other adapters read straight off their responses. + 'sha' => \hash('sha1', 'blob ' . \strlen($content) . "\0" . $content), + 'size' => \strlen($content), 'content' => $content, ]; } @@ -513,7 +495,9 @@ public function listRepositoryLanguages(string $owner, string $repositoryName): public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file', string $branch = ''): array { if (empty($branch)) { - $branch = $this->resolveDefaultBranch($owner, $repositoryName); + // Bitbucket names an empty repository's branch after whatever the + // first commit asks for, so default to 'main' + $branch = $this->mainBranchName($owner, $repositoryName) ?: 'main'; } $url = "/repositories/{$owner}/{$repositoryName}/src"; @@ -557,16 +541,6 @@ public function createFile(string $owner, string $repositoryName, string $filepa ]; } - /** - * Name of the branch a commit lands on when the caller didn't pick one. - * Bitbucket names an empty repository's branch after whatever the first - * commit asks for, so default to 'main'. - */ - private function resolveDefaultBranch(string $owner, string $repositoryName): string - { - return $this->mainBranchName($owner, $repositoryName) ?: 'main'; - } - public function createBranch(string $owner, string $repositoryName, string $newBranchName, string $oldBranchName): array { $url = "/repositories/{$owner}/{$repositoryName}/refs/branches"; @@ -1341,18 +1315,6 @@ private function isBranchChange(array $change): bool return \in_array($type, ['branch', 'named_branch'], true); } - /** - * @param array $repository - * @return array{owner: string, url: string} - */ - private function getEventRepositoryOwner(array $repository): array - { - return [ - 'owner' => $this->workspaceSlugOf($repository), - 'url' => (string) ($repository['links']['html']['href'] ?? ''), - ]; - } - /** * @param array $change * @param array $repository @@ -1362,7 +1324,8 @@ private function getEventRepositoryOwner(array $repository): array private function parsePushChange(array $change, array $repository, array $actor): array { $actorLinks = is_array($actor['links'] ?? null) ? $actor['links'] : []; - ['owner' => $owner, 'url' => $repositoryUrl] = $this->getEventRepositoryOwner($repository); + $owner = $this->workspaceSlugOf($repository); + $repositoryUrl = (string) ($repository['links']['html']['href'] ?? ''); $new = is_array($change['new'] ?? null) ? $change['new'] : []; $old = is_array($change['old'] ?? null) ? $change['old'] : []; @@ -1414,7 +1377,8 @@ private function parsePushChange(array $change, array $repository, array $actor) private function parsePullRequestEvent(string $event, array $payloadArray, array $repository, array $actor): array { $actorLinks = is_array($actor['links'] ?? null) ? $actor['links'] : []; - ['owner' => $owner, 'url' => $repositoryUrl] = $this->getEventRepositoryOwner($repository); + $owner = $this->workspaceSlugOf($repository); + $repositoryUrl = (string) ($repository['links']['html']['href'] ?? ''); $pullRequest = is_array($payloadArray['pullrequest'] ?? null) ? $payloadArray['pullrequest'] : []; $source = is_array($pullRequest['source'] ?? null) ? $pullRequest['source'] : []; diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 62e1716b..81743e45 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -185,43 +185,13 @@ private function eventActor(): array /** * Bitbucket rejects a build status with no url, so the adapter points one - * that was written without a url at the commit it describes. + * written without a url at the commit it describes. + * + * @param array $status */ - public function testUpdateCommitStatusDefaultsUrlToCommit(): void + protected function assertCommitStatusUrl(array $status, string $commitUrl): void { - $repositoryName = 'test-update-commit-status-url-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; - - $this->vcsAdapter->updateCommitStatus( - $repositoryName, - $commitHash, - static::$owner, - 'pending', - 'Build started', - '', - 'ci/test' - ); - - $written = null; - foreach ($this->vcsAdapter->getCommitStatuses(static::$owner, $repositoryName, $commitHash) as $status) { - if ($status['context'] === 'ci/test') { - $written = $status; - } - } - - $this->assertNotNull($written, 'No status reported under the context it was written with'); - $this->assertSame('pending', $written['state']); - $this->assertSame( - $this->vcsAdapter->getCommitUrl(static::$owner, $repositoryName, $commitHash), - $written['target_url'] - ); - } finally { - $this->discardRepositories($repositoryName); - } + $this->assertSame($commitUrl, $status['target_url']); } /** @@ -258,10 +228,7 @@ public function testGetEventsReportsEveryPushedBranch(): void ], ]); - /** @var Bitbucket $adapter */ - $adapter = $this->vcsAdapter; - - $events = $adapter->getEvents(static::$pushEventName, $payload); + $events = $this->vcsAdapter->getEvents(static::$pushEventName, $payload); // The tag between them is left out $this->assertCount(2, $events); @@ -270,15 +237,15 @@ public function testGetEventsReportsEveryPushedBranch(): void $this->assertTrue($events[1]['branchCreated']); // getEvent() reports the first of them - $this->assertSame($events[0], $adapter->getEvent(static::$pushEventName, $payload)); + $this->assertSame($events[0], $this->vcsAdapter->getEvent(static::$pushEventName, $payload)); - // Leaving a push with nothing but tags no branch to report at all + // A push carrying nothing but tags has no branch to report $tagsOnly = (string) json_encode([ 'repository' => $this->eventRepository(), 'push' => ['changes' => [['new' => ['type' => 'tag', 'name' => 'v1.0.0', 'target' => ['hash' => 'aaa111']]]]], ]); - $this->assertSame([], $adapter->getEvent(static::$pushEventName, $tagsOnly)); + $this->assertSame([], $this->vcsAdapter->getEvent(static::$pushEventName, $tagsOnly)); } public function testGetEventPullRequestActionMapping(): void @@ -296,5 +263,4 @@ public function testGetEventPullRequestActionMapping(): void $this->assertSame($action, $result['action'], "event '{$event}' should map to '{$action}'"); } } - } diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index b220710f..8115fd82 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -1765,7 +1765,7 @@ public function testWebhookPullRequestEvent(): void $secret, ['pull_request'] ); - $this->assertGreaterThan(0, $webhookId); + $this->assertNotEmpty($webhookId); $this->deleteLastWebhookRequest(); @@ -1898,12 +1898,30 @@ public function testGetCommitStatuses(): void $this->assertArrayHasKey('description', $status); $this->assertArrayHasKey('target_url', $status); $this->assertArrayHasKey('context', $status); + + if ($status['context'] === 'ci/test') { + $this->assertCommitStatusUrl( + $status, + $this->vcsAdapter->getCommitUrl(static::$owner, $repositoryName, $commitHash) + ); + } } } finally { $this->discardRepositories($repositoryName); } } + /** + * What a provider reports as target_url for the status written above with + * no url of its own. Most leave it empty; override where the provider + * rejects a status without one. + * + * @param array $status + */ + protected function assertCommitStatusUrl(array $status, string $commitUrl): void + { + } + public function testGetCommitStatusesEmptyForNewCommit(): void { $this->skipUnlessSupported(static::$supportsCommitStatusLookup, 'reading commit statuses'); From 42929819a91eda6d4f72dee39d990df477f62cad Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 3 Aug 2026 10:51:07 +0530 Subject: [PATCH 26/34] refactor: scope the PR back to Bitbucket Reverts everything that wasn't Bitbucket's to change: - deleteWebhook() is gone entirely. It had no caller anywhere -- not in Appwrite (RepositoryWebhooks only ever creates), not in cloud, and not in the suite except the tests written to exercise it. Test cleanup didn't need it either, since discardRepositories() deletes the whole repository. With it goes the uuid-recovery fallback: createWebhook() throws again when Bitbucket omits the uuid, as GitHub.php does, and Base loses $supportsWebhookCreation and the create/delete test, which existed only to drive it. - createWebhook() moves back to Git.php where it was; only its return type widens to int|string, which Bitbucket needs to satisfy the existing contract at all. - GitHub.php, GitLab.php, Gitea.php and GitHubTest.php are untouched again, as is the README table for the other providers. - Dropped testUpdateCommitStatusDefaultsUrlToCommit rather than keeping it or moving it into Base behind a hook. Base::testGetCommitStatuses already writes a status with an empty target_url and reads it back, so it already covers the defaulting for Bitbucket; the extra test only pinned which url was substituted, at the cost of repeating the whole repository round trip. What still touches shared files is what Bitbucket cannot run without: getEvents() on Adapter (its push batches refs), and in Base the repositoryIdOf() hook, the $reportsAffectedFilesInPushEvent flag, the self:: -> static:: reads so an adapter can restate an EVENT_* fact, and two skips for capabilities Bitbucket lacks. --- README.md | 7 +--- src/VCS/Adapter.php | 23 ----------- src/VCS/Adapter/Git.php | 15 ++++++++ src/VCS/Adapter/Git/Bitbucket.php | 58 +--------------------------- src/VCS/Adapter/Git/GitHub.php | 15 -------- src/VCS/Adapter/Git/GitLab.php | 17 --------- src/VCS/Adapter/Git/Gitea.php | 15 -------- tests/VCS/Adapter/BitbucketTest.php | 11 ------ tests/VCS/Adapter/GitHubTest.php | 4 -- tests/VCS/Base.php | 59 +---------------------------- 10 files changed, 20 insertions(+), 204 deletions(-) diff --git a/README.md b/README.md index 20a01605..d7cc8926 100644 --- a/README.md +++ b/README.md @@ -69,11 +69,8 @@ VCS Adapters: | Adapter | Status | |---------|---------| | GitHub | ✅ | -| GitLab | ✅ | -| Gitea | ✅ | -| Forgejo | ✅ | -| Gogs | ✅ | -| Bitbucket | ✅ | +| GitLab | | +| Bitbucket | | | Azure DevOps | | `✅ - supported, 🛠 - work in progress` diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index 310ac194..76c72f86 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -275,29 +275,6 @@ abstract public function getSignatureHeaderName(): string; */ abstract public function getSupportedWebhookScopes(): array; - /** - * Create a webhook on a repository - * - * @param string $owner Owner of the repository - * @param string $repositoryName Name of the repository - * @param string $url Webhook URL to send events to - * @param string $secret Webhook secret for signature validation - * @param array $events Events to trigger the webhook - * @return int|string Webhook ID, as the provider identifies it: an int on - * the providers that number their hooks, a string where - * they don't (Bitbucket identifies them by UUID) - */ - abstract public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int|string; - - /** - * Delete a webhook from a repository. - * - * @param string $owner Owner of the repository - * @param string $repositoryName Name of the repository - * @param int|string $webhookId Webhook ID as returned by createWebhook() - */ - abstract public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool; - /** * Browser-facing URL for a repository's home page. */ diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index cd8e0ad9..f0ba9bff 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -72,6 +72,21 @@ abstract public function createBranch(string $owner, string $repositoryName, str */ abstract public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array; + /** + * Create a webhook on a repository + * + * @param string $owner Owner of the repository + * @param string $repositoryName Name of the repository + * @param string $url Webhook URL to send events to + * @param string $secret Webhook secret for signature validation + * @param array $events Events to trigger the webhook + * @return int|string Webhook ID, as the provider identifies it: an int on + * the providers that number their hooks, a string where + * they don't (Bitbucket identifies them by UUID) + */ + abstract public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int|string; + + /** * Create a tag in a repository * diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 10f12a53..2a266d92 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -1004,68 +1004,12 @@ public function createWebhook(string $owner, string $repositoryName, string $url $uuid = $response['body']['uuid'] ?? null; if ($uuid === null || $uuid === '') { - // The hook is live even without a uuid in the response; recover it - // from the hook list rather than leaving it undeletable. - $uuid = $this->findSingleWebhookByUrl($owner, $repositoryName, $url); - } - - if (empty($uuid)) { - throw new Exception("Webhook created but its uuid could not be safely resolved; check {$owner}/{$repositoryName}'s webhooks at {$url} manually"); + throw new Exception('Webhook created but response did not include a uuid'); } return (string) $uuid; } - /** - * Uuid of the one repository webhook delivering to this url, or null if - * there is none or more than one -- callers can't tell which of several - * matches is theirs, so this refuses to guess. - */ - private function findSingleWebhookByUrl(string $owner, string $repositoryName, string $url): ?string - { - $matches = []; - - $page = 1; - do { - $apiUrl = "/repositories/{$owner}/{$repositoryName}/hooks?pagelen=" . self::PAGE_SIZE . "&page={$page}"; - - $response = $this->call(self::METHOD_GET, $apiUrl, ['Authorization' => 'Bearer ' . $this->accessToken]); - - $responseHeaders = $response['headers'] ?? []; - if (($responseHeaders['status-code'] ?? 0) >= 400) { - break; - } - - $responseBody = $response['body'] ?? []; - $values = is_array($responseBody) ? ($responseBody['values'] ?? []) : []; - - foreach ($values as $hook) { - if (is_array($hook) && ($hook['url'] ?? null) === $url) { - $matches[] = (string) ($hook['uuid'] ?? ''); - } - } - - $page++; - } while (!empty($responseBody['next'])); - - return count($matches) === 1 ? $matches[0] : null; - } - - public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool - { - $url = "/repositories/{$owner}/{$repositoryName}/hooks/" . rawurlencode((string) $webhookId); - - $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); - - $responseHeaders = $response['headers'] ?? []; - $statusCode = $responseHeaders['status-code'] ?? 0; - if ($statusCode >= 400) { - throw new Exception("Failed to delete webhook: HTTP {$statusCode}", $statusCode); - } - - return true; - } - /** * Translate this library's event names into Bitbucket's event keys. A pull * request maps to several of them, since Bitbucket splits open, update, diff --git a/src/VCS/Adapter/Git/GitHub.php b/src/VCS/Adapter/Git/GitHub.php index db951483..6f052061 100644 --- a/src/VCS/Adapter/Git/GitHub.php +++ b/src/VCS/Adapter/Git/GitHub.php @@ -160,21 +160,6 @@ public function createWebhook(string $owner, string $repositoryName, string $url return (int) $id; } - public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool - { - $url = "/repos/{$owner}/{$repositoryName}/hooks/{$webhookId}"; - - $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => "Bearer $this->accessToken"]); - - $responseHeaders = $response['headers'] ?? []; - $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; - if ($responseHeadersStatusCode >= 400) { - throw new Exception("Failed to delete webhook: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode); - } - - return true; - } - /** * Create a file in a repository * diff --git a/src/VCS/Adapter/Git/GitLab.php b/src/VCS/Adapter/Git/GitLab.php index 4e989580..34416911 100644 --- a/src/VCS/Adapter/Git/GitLab.php +++ b/src/VCS/Adapter/Git/GitLab.php @@ -597,23 +597,6 @@ public function createWebhook(string $owner, string $repositoryName, string $url return $responseBody['id'] ?? 0; } - public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool - { - $ownerPath = $this->getOwnerPath($owner); - $projectPath = urlencode("{$ownerPath}/{$repositoryName}"); - $url = "/projects/{$projectPath}/hooks/{$webhookId}"; - - $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); - - $responseHeaders = $response['headers'] ?? []; - $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; - if ($responseHeadersStatusCode >= 400) { - throw new Exception("Failed to delete webhook: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode); - } - - return true; - } - public function createComment(string $owner, string $repositoryName, int $pullRequestNumber, string $comment): string { $ownerPath = $this->getOwnerPath($owner); diff --git a/src/VCS/Adapter/Git/Gitea.php b/src/VCS/Adapter/Git/Gitea.php index ffd401a1..8d630dad 100644 --- a/src/VCS/Adapter/Git/Gitea.php +++ b/src/VCS/Adapter/Git/Gitea.php @@ -597,21 +597,6 @@ public function createWebhook(string $owner, string $repositoryName, string $url return (int) ($response['body']['id'] ?? 0); } - public function deleteWebhook(string $owner, string $repositoryName, int|string $webhookId): bool - { - $url = "/repos/{$owner}/{$repositoryName}/hooks/{$webhookId}"; - - $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => "token $this->accessToken"]); - - $responseHeaders = $response['headers'] ?? []; - $responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0; - if ($responseHeadersStatusCode >= 400) { - throw new Exception("Failed to delete webhook: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode); - } - - return true; - } - public function createComment(string $owner, string $repositoryName, int $pullRequestNumber, string $comment): string { $url = "/repos/{$owner}/{$repositoryName}/issues/{$pullRequestNumber}/comments"; diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 81743e45..eaf89d35 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -183,17 +183,6 @@ private function eventActor(): array ]; } - /** - * Bitbucket rejects a build status with no url, so the adapter points one - * written without a url at the commit it describes. - * - * @param array $status - */ - protected function assertCommitStatusUrl(array $status, string $commitUrl): void - { - $this->assertSame($commitUrl, $status['target_url']); - } - /** * Bitbucket only names the author in a raw "Name " string; a commit * linked to an account is named by the account instead. diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index 5da7f55c..17d57374 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -23,10 +23,6 @@ class GitHubTest extends Base protected static bool $supportsUserLookup = false; protected static bool $computesLanguagesAsynchronously = true; protected static bool $supportsWebhookDelivery = false; - - // A GitHub App token gets 403 on the classic per-repo webhook endpoint - protected static bool $supportsWebhookCreation = false; - protected static bool $resolvesOwnerFromRepositoryId = false; protected static bool $rejectsInvalidRepositoryNames = false; diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 8115fd82..c0f47cd2 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -125,10 +125,6 @@ abstract class Base extends TestCase protected static bool $supportsWebhookDelivery = true; - // Whether creating a webhook through the API works at all, separate from - // whether delivery can reach the test catcher. - protected static bool $supportsWebhookCreation = true; - protected static bool $resolvesOwnerFromRepositoryId = true; protected static bool $rejectsInvalidRepositoryNames = true; @@ -1690,7 +1686,7 @@ public function testWebhookPushEvent(): void $secret, ['push'] ); - $this->assertNotEmpty($webhookId); + $this->assertGreaterThan(0, $webhookId); $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Webhook Test', 'Initial commit'); @@ -1700,39 +1696,6 @@ public function testWebhookPushEvent(): void $this->assertSame($repositoryName, $event['repositoryName']); $this->assertSame($this->ownerPath(), $event['owner']); $this->assertNotEmpty($event['commitHash']); - - $this->assertTrue($this->vcsAdapter->deleteWebhook(static::$owner, $repositoryName, $webhookId)); - } finally { - $this->discardRepositories($repositoryName); - } - } - - /** - * Covers createWebhook()/deleteWebhook() through the API alone, for an - * adapter testWebhookPushEvent() skips. - */ - public function testCreateAndDeleteWebhookWithoutDelivery(): void - { - if (static::$supportsWebhookDelivery) { - $this->markTestSkipped('webhook deletion is already covered by testWebhookPushEvent()'); - } - - $this->skipUnlessSupported(static::$supportsWebhookCreation, 'creating a webhook through the API'); - - $repositoryName = 'test-create-delete-webhook-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $webhookId = $this->vcsAdapter->createWebhook( - static::$owner, - $repositoryName, - 'https://example.com/webhook', - 'secret-token', - ['push', 'pull_request'] - ); - $this->assertNotEmpty($webhookId); - - $this->assertTrue($this->vcsAdapter->deleteWebhook(static::$owner, $repositoryName, $webhookId)); } finally { $this->discardRepositories($repositoryName); } @@ -1765,7 +1728,7 @@ public function testWebhookPullRequestEvent(): void $secret, ['pull_request'] ); - $this->assertNotEmpty($webhookId); + $this->assertGreaterThan(0, $webhookId); $this->deleteLastWebhookRequest(); @@ -1898,30 +1861,12 @@ public function testGetCommitStatuses(): void $this->assertArrayHasKey('description', $status); $this->assertArrayHasKey('target_url', $status); $this->assertArrayHasKey('context', $status); - - if ($status['context'] === 'ci/test') { - $this->assertCommitStatusUrl( - $status, - $this->vcsAdapter->getCommitUrl(static::$owner, $repositoryName, $commitHash) - ); - } } } finally { $this->discardRepositories($repositoryName); } } - /** - * What a provider reports as target_url for the status written above with - * no url of its own. Most leave it empty; override where the provider - * rejects a status without one. - * - * @param array $status - */ - protected function assertCommitStatusUrl(array $status, string $commitUrl): void - { - } - public function testGetCommitStatusesEmptyForNewCommit(): void { $this->skipUnlessSupported(static::$supportsCommitStatusLookup, 'reading commit statuses'); From c453a0324136464a7cfae1962fa94971c0275d3e Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 3 Aug 2026 11:10:12 +0530 Subject: [PATCH 27/34] ci: mint the Bitbucket test token per run instead of storing one An OAuth consumer is free where a workspace access token may not be, and the token it hands back lasts two hours, so CI mints one before starting the stack rather than keeping a long-lived credential around. The value goes straight into GITHUB_ENV, masked, and is never stored as a secret -- only the consumer's client id and secret are, since minting has to authenticate as the consumer. With no consumer configured the step is a no-op and the suite skips, the same as it does today. Also documents in CONTRIBUTING why an Atlassian account API token (the ATATT kind) answers 401 here: it authenticates as email:token over HTTP Basic, and the adapter sends the token as a Bearer credential. --- .github/workflows/tests-external.yml | 27 ++++++++++++++++++++++++++- .github/workflows/tests.yml | 27 ++++++++++++++++++++++++++- CONTRIBUTING.md | 17 +++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests-external.yml b/.github/workflows/tests-external.yml index 7e21070e..4bbe7781 100644 --- a/.github/workflows/tests-external.yml +++ b/.github/workflows/tests-external.yml @@ -25,12 +25,37 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} + - name: Mint Bitbucket access token + if: matrix.adapter == 'bitbucket' + env: + CLIENT_ID: ${{ secrets.TESTS_BITBUCKET_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.TESTS_BITBUCKET_CLIENT_SECRET }} + run: | + # Minted per run rather than stored: an OAuth consumer is free, and the + # token it hands back lasts two hours, so nothing long-lived is kept. + # With no consumer configured the suite skips, as it does unconfigured. + if [ -z "$CLIENT_ID" ]; then + echo "No Bitbucket OAuth consumer configured; the suite will skip." + exit 0 + fi + + token=$(curl -sf -X POST -u "$CLIENT_ID:$CLIENT_SECRET" \ + https://bitbucket.org/site/oauth2/access_token \ + -d grant_type=client_credentials | jq -r '.access_token // empty') + + if [ -z "$token" ]; then + echo "::error::Could not mint a Bitbucket token from the configured OAuth consumer" + exit 1 + fi + + echo "::add-mask::$token" + echo "TESTS_BITBUCKET_ACCESS_TOKEN=$token" >> "$GITHUB_ENV" + - name: Start Test Stack env: TESTS_GITHUB_PRIVATE_KEY: ${{ secrets.TESTS_GITHUB_PRIVATE_KEY }} TESTS_GITHUB_APP_IDENTIFIER: ${{ secrets.TESTS_GITHUB_APP_IDENTIFIER }} TESTS_GITHUB_INSTALLATION_ID: ${{ secrets.TESTS_GITHUB_INSTALLATION_ID }} - TESTS_BITBUCKET_ACCESS_TOKEN: ${{ secrets.TESTS_BITBUCKET_ACCESS_TOKEN }} TESTS_BITBUCKET_WORKSPACE: ${{ secrets.TESTS_BITBUCKET_WORKSPACE }} run: | docker compose --profile ${{ matrix.adapter }} up -d --wait --wait-timeout 900 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 60a7234f..aaffe368 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,12 +21,37 @@ jobs: - name: Check out the repo uses: actions/checkout@v4 + - name: Mint Bitbucket access token + if: matrix.adapter == 'bitbucket' + env: + CLIENT_ID: ${{ secrets.TESTS_BITBUCKET_CLIENT_ID }} + CLIENT_SECRET: ${{ secrets.TESTS_BITBUCKET_CLIENT_SECRET }} + run: | + # Minted per run rather than stored: an OAuth consumer is free, and the + # token it hands back lasts two hours, so nothing long-lived is kept. + # With no consumer configured the suite skips, as it does unconfigured. + if [ -z "$CLIENT_ID" ]; then + echo "No Bitbucket OAuth consumer configured; the suite will skip." + exit 0 + fi + + token=$(curl -sf -X POST -u "$CLIENT_ID:$CLIENT_SECRET" \ + https://bitbucket.org/site/oauth2/access_token \ + -d grant_type=client_credentials | jq -r '.access_token // empty') + + if [ -z "$token" ]; then + echo "::error::Could not mint a Bitbucket token from the configured OAuth consumer" + exit 1 + fi + + echo "::add-mask::$token" + echo "TESTS_BITBUCKET_ACCESS_TOKEN=$token" >> "$GITHUB_ENV" + - name: Start Test Stack env: TESTS_GITHUB_PRIVATE_KEY: ${{ secrets.TESTS_GITHUB_PRIVATE_KEY }} TESTS_GITHUB_APP_IDENTIFIER: ${{ secrets.TESTS_GITHUB_APP_IDENTIFIER }} TESTS_GITHUB_INSTALLATION_ID: ${{ secrets.TESTS_GITHUB_INSTALLATION_ID }} - TESTS_BITBUCKET_ACCESS_TOKEN: ${{ secrets.TESTS_BITBUCKET_ACCESS_TOKEN }} TESTS_BITBUCKET_WORKSPACE: ${{ secrets.TESTS_BITBUCKET_WORKSPACE }} run: | docker compose --profile ${{ matrix.adapter }} up -d --wait --wait-timeout 900 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5f74cd8..70fbd1fd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,6 +107,23 @@ The `bitbucket` adapter runs against Bitbucket Cloud, which has no self-hostable The workspace needs at least one project, since Bitbucket assigns every new repository to one. Without these variables the suite is skipped, the same way the `github` one is. +The adapter sends the token as a Bearer credential, so an Atlassian account +API token (the `ATATT…` kind, which authenticates as `email:token` over HTTP +Basic) will not work — every call answers 401. Create a private OAuth consumer +under `bitbucket.org//workspace/settings/api`, scoped for `account`, +`repository`, `repository:admin`, `pullrequest`, `pullrequest:write` and +`webhook`, then mint a token from it: + +```bash +export TESTS_BITBUCKET_ACCESS_TOKEN=$(curl -sf -X POST -u "$CLIENT_ID:$CLIENT_SECRET" \ + https://bitbucket.org/site/oauth2/access_token -d grant_type=client_credentials \ + | jq -r .access_token) +``` + +The token lasts two hours and can be re-minted on demand, so nothing long-lived +needs storing. CI does the same thing from `TESTS_BITBUCKET_CLIENT_ID` and +`TESTS_BITBUCKET_CLIENT_SECRET` before it starts the stack. + ## Adding A New Adapter You can follow our [Adding new VCS Adapter](docs/add-new-vcs-adapter.md) tutorial to add a new VCS adapter like GitLab, Bitbucket etc. in this library. From ec1ec9374ec098121e0b675708a854b1895aa6d1 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 3 Aug 2026 11:22:21 +0530 Subject: [PATCH 28/34] ci: report what Bitbucket says when minting a token fails curl -sf discards the error body, so a refused token request surfaced as a bare "could not mint" with nothing to act on. Captures the status and body instead and echoes Bitbucket's own error_description. --- .github/workflows/tests-external.yml | 12 +++++++++--- .github/workflows/tests.yml | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/tests-external.yml b/.github/workflows/tests-external.yml index 4bbe7781..f023a076 100644 --- a/.github/workflows/tests-external.yml +++ b/.github/workflows/tests-external.yml @@ -39,12 +39,18 @@ jobs: exit 0 fi - token=$(curl -sf -X POST -u "$CLIENT_ID:$CLIENT_SECRET" \ + response=$(curl -s -w '\n%{http_code}' -X POST -u "$CLIENT_ID:$CLIENT_SECRET" \ https://bitbucket.org/site/oauth2/access_token \ - -d grant_type=client_credentials | jq -r '.access_token // empty') + -d grant_type=client_credentials) + status=$(printf '%s' "$response" | tail -n1) + body=$(printf '%s' "$response" | sed '$d') + + token=$(printf '%s' "$body" | jq -r '.access_token // empty' 2>/dev/null) if [ -z "$token" ]; then - echo "::error::Could not mint a Bitbucket token from the configured OAuth consumer" + # Report what Bitbucket said; the body carries no token to leak here + reason=$(printf '%s' "$body" | jq -r '.error_description // .error // empty' 2>/dev/null) + echo "::error::Bitbucket refused the token request (HTTP $status): ${reason:-no error body}" exit 1 fi diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aaffe368..e73bc989 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -35,12 +35,18 @@ jobs: exit 0 fi - token=$(curl -sf -X POST -u "$CLIENT_ID:$CLIENT_SECRET" \ + response=$(curl -s -w '\n%{http_code}' -X POST -u "$CLIENT_ID:$CLIENT_SECRET" \ https://bitbucket.org/site/oauth2/access_token \ - -d grant_type=client_credentials | jq -r '.access_token // empty') + -d grant_type=client_credentials) + status=$(printf '%s' "$response" | tail -n1) + body=$(printf '%s' "$response" | sed '$d') + + token=$(printf '%s' "$body" | jq -r '.access_token // empty' 2>/dev/null) if [ -z "$token" ]; then - echo "::error::Could not mint a Bitbucket token from the configured OAuth consumer" + # Report what Bitbucket said; the body carries no token to leak here + reason=$(printf '%s' "$body" | jq -r '.error_description // .error // empty' 2>/dev/null) + echo "::error::Bitbucket refused the token request (HTTP $status): ${reason:-no error body}" exit 1 fi From 9369f7be92e05c904f13deaa060582e82f3e356c Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 3 Aug 2026 11:39:50 +0530 Subject: [PATCH 29/34] fix: say what Bitbucket objected to when creating a repository fails createRepository() reported only the status code, so a 400 gave nothing to act on. createWebhook() already includes the response body; this matches it. --- src/VCS/Adapter/Git/Bitbucket.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index 2a266d92..b4777aeb 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -221,7 +221,11 @@ public function createRepository(string $owner, string $repositoryName, bool $pr $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Creating repository {$repositoryName} failed with status code {$statusCode}", $statusCode); + $error = $response['body']['error']['message'] ?? ''; + throw new Exception( + "Creating repository {$repositoryName} failed with status code {$statusCode}" . ($error !== '' ? ": {$error}" : ''), + $statusCode + ); } $responseBody = $response['body'] ?? []; From e737a29248cb489d59b9845830dde210e6966536 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 3 Aug 2026 12:39:20 +0530 Subject: [PATCH 30/34] docs: drop the token-minting walkthrough from CONTRIBUTING CI mints its own token, so the manual steps were setup lore rather than something a contributor needs in the repo. --- CONTRIBUTING.md | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70fbd1fd..b5f74cd8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -107,23 +107,6 @@ The `bitbucket` adapter runs against Bitbucket Cloud, which has no self-hostable The workspace needs at least one project, since Bitbucket assigns every new repository to one. Without these variables the suite is skipped, the same way the `github` one is. -The adapter sends the token as a Bearer credential, so an Atlassian account -API token (the `ATATT…` kind, which authenticates as `email:token` over HTTP -Basic) will not work — every call answers 401. Create a private OAuth consumer -under `bitbucket.org//workspace/settings/api`, scoped for `account`, -`repository`, `repository:admin`, `pullrequest`, `pullrequest:write` and -`webhook`, then mint a token from it: - -```bash -export TESTS_BITBUCKET_ACCESS_TOKEN=$(curl -sf -X POST -u "$CLIENT_ID:$CLIENT_SECRET" \ - https://bitbucket.org/site/oauth2/access_token -d grant_type=client_credentials \ - | jq -r .access_token) -``` - -The token lasts two hours and can be re-minted on demand, so nothing long-lived -needs storing. CI does the same thing from `TESTS_BITBUCKET_CLIENT_ID` and -`TESTS_BITBUCKET_CLIENT_SECRET` before it starts the stack. - ## Adding A New Adapter You can follow our [Adding new VCS Adapter](docs/add-new-vcs-adapter.md) tutorial to add a new VCS adapter like GitLab, Bitbucket etc. in this library. From 6e5881adcdd8a5d58d8cd56757bab539545f9978 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 3 Aug 2026 12:50:25 +0530 Subject: [PATCH 31/34] test: only read the constant a subclass restates through static:: EVENT_REPOSITORY_ID is the one EVENT_* fact an adapter overrides, so it is the only one that needs late static binding to resolve. The other eight were changed for consistency and are churn this PR doesn't need. --- tests/VCS/Base.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index c0f47cd2..3f27c2ce 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -2349,12 +2349,12 @@ public function testGetEventPush(): void $this->assertSame(static::$defaultBranch, $result['branch']); $this->assertSame(static::EVENT_REPOSITORY_ID, $result['repositoryId']); - $this->assertSame(static::EVENT_REPOSITORY_NAME, $result['repositoryName']); - $this->assertSame(static::EVENT_OWNER, $result['owner']); - $this->assertSame(static::EVENT_COMMIT_HASH, $result['commitHash']); - $this->assertSame(static::EVENT_COMMIT_MESSAGE, $result['headCommitMessage']); - $this->assertSame(static::EVENT_AUTHOR_NAME, $result['headCommitAuthorName']); - $this->assertSame(static::EVENT_AUTHOR_EMAIL, $result['headCommitAuthorEmail']); + $this->assertSame(self::EVENT_REPOSITORY_NAME, $result['repositoryName']); + $this->assertSame(self::EVENT_OWNER, $result['owner']); + $this->assertSame(self::EVENT_COMMIT_HASH, $result['commitHash']); + $this->assertSame(self::EVENT_COMMIT_MESSAGE, $result['headCommitMessage']); + $this->assertSame(self::EVENT_AUTHOR_NAME, $result['headCommitAuthorName']); + $this->assertSame(self::EVENT_AUTHOR_EMAIL, $result['headCommitAuthorEmail']); $this->assertNotEmpty($result['headCommitUrl']); $this->assertNotEmpty($result['repositoryUrl']); $this->assertNotEmpty($result['branchUrl']); @@ -2393,12 +2393,12 @@ public function testGetEventPullRequest(): void $result = $this->vcsAdapter->getEvent(static::$pullRequestEventName, $this->pullRequestPayload()); $this->assertSame('opened', $result['action']); - $this->assertSame(static::EVENT_HEAD_BRANCH, $result['branch']); - $this->assertSame(static::EVENT_PULL_REQUEST_NUMBER, $result['pullRequestNumber']); + $this->assertSame(self::EVENT_HEAD_BRANCH, $result['branch']); + $this->assertSame(self::EVENT_PULL_REQUEST_NUMBER, $result['pullRequestNumber']); $this->assertSame(static::EVENT_REPOSITORY_ID, $result['repositoryId']); - $this->assertSame(static::EVENT_REPOSITORY_NAME, $result['repositoryName']); - $this->assertSame(static::EVENT_OWNER, $result['owner']); - $this->assertSame(static::EVENT_COMMIT_HASH, $result['commitHash']); + $this->assertSame(self::EVENT_REPOSITORY_NAME, $result['repositoryName']); + $this->assertSame(self::EVENT_OWNER, $result['owner']); + $this->assertSame(self::EVENT_COMMIT_HASH, $result['commitHash']); $this->assertFalse($result['external']); } From aa49260a29caa374540beb6d4a7a3576a4642d52 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 3 Aug 2026 12:58:01 +0530 Subject: [PATCH 32/34] fix: don't name a branch Bitbucket hasn't created yet createFile() defaulted an empty repository's branch to 'main' on the belief that Bitbucket names the first branch after whatever the commit asks for. It doesn't -- the commit lands on Bitbucket's own default and 'main' never exists, so every later call that named it failed: 404 from getLatestCommit(), 400 from createBranch() resolving it as a target, and empty results from listSource(), which swallows the 404 into []. It only ever surfaced now because the suite skipped for want of credentials until this run. Omits the branch when there is none to name, letting Bitbucket create its default, and declares that default as 'master' in BitbucketTest the way GogsTest already does for Gogs. --- src/VCS/Adapter/Git/Bitbucket.php | 12 ++++++++---- tests/VCS/Adapter/BitbucketTest.php | 3 ++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index b4777aeb..bb95bd3e 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -499,9 +499,7 @@ public function listRepositoryLanguages(string $owner, string $repositoryName): public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file', string $branch = ''): array { if (empty($branch)) { - // Bitbucket names an empty repository's branch after whatever the - // first commit asks for, so default to 'main' - $branch = $this->mainBranchName($owner, $repositoryName) ?: 'main'; + $branch = $this->mainBranchName($owner, $repositoryName); } $url = "/repositories/{$owner}/{$repositoryName}/src"; @@ -511,10 +509,16 @@ public function createFile(string $owner, string $repositoryName, string $filepa // treats every path as absolute from the repository root either way. $payload = [ 'message' => $message, - 'branch' => $branch, '/' . $this->normalizeRepositoryPath($filepath) => $content, ]; + // An empty repository has no branch to commit onto yet, and naming one + // Bitbucket doesn't have is refused; omitting it lets Bitbucket create + // its own default, which the caller reads back off the repository. + if (!empty($branch)) { + $payload['branch'] = $branch; + } + $response = $this->call( self::METHOD_POST, $url, diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index eaf89d35..d3a3dfde 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -21,7 +21,8 @@ class BitbucketTest extends Base protected static string $accessToken = ''; protected static string $owner = ''; - protected static string $defaultBranch = 'main'; + // Bitbucket names an empty repository's first branch 'master' + protected static string $defaultBranch = 'master'; protected static string $eventHeader = 'x-event-key'; protected static string $signatureHeader = 'x-hub-signature'; protected static string $pushEventName = 'repo:push'; From 1531cb7c1ffa2fe7a48b0e121fc6cfb247b79cb9 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 3 Aug 2026 13:26:32 +0530 Subject: [PATCH 33/34] feat: accept an Atlassian account API token as email:token The adapter only spoke Bearer, so an Atlassian account API token -- the ATATT kind, which authenticates as email:token over HTTP Basic -- answered 401 on every call, and testing meant standing up an OAuth consumer with the client-credentials grant enabled. An email:token pair always carries a colon and a bare token never does, so the credential names its own scheme and initializeVariables() keeps its signature. Centralising the header also collapses 26 copies of 'Bearer ' . $this->accessToken into one place, and clone/archive URLs reuse the same distinction: an email:token pair is already valid userinfo, where a bare token needs the x-token-auth username Bitbucket pairs it with. CI drops the minting step it needed for client credentials and passes TESTS_BITBUCKET_ACCESS_TOKEN straight through again. --- .github/workflows/tests-external.yml | 33 +---------- .github/workflows/tests.yml | 33 +---------- CONTRIBUTING.md | 2 +- src/VCS/Adapter/Git/Bitbucket.php | 87 ++++++++++++++++++---------- 4 files changed, 58 insertions(+), 97 deletions(-) diff --git a/.github/workflows/tests-external.yml b/.github/workflows/tests-external.yml index f023a076..7e21070e 100644 --- a/.github/workflows/tests-external.yml +++ b/.github/workflows/tests-external.yml @@ -25,43 +25,12 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} - - name: Mint Bitbucket access token - if: matrix.adapter == 'bitbucket' - env: - CLIENT_ID: ${{ secrets.TESTS_BITBUCKET_CLIENT_ID }} - CLIENT_SECRET: ${{ secrets.TESTS_BITBUCKET_CLIENT_SECRET }} - run: | - # Minted per run rather than stored: an OAuth consumer is free, and the - # token it hands back lasts two hours, so nothing long-lived is kept. - # With no consumer configured the suite skips, as it does unconfigured. - if [ -z "$CLIENT_ID" ]; then - echo "No Bitbucket OAuth consumer configured; the suite will skip." - exit 0 - fi - - response=$(curl -s -w '\n%{http_code}' -X POST -u "$CLIENT_ID:$CLIENT_SECRET" \ - https://bitbucket.org/site/oauth2/access_token \ - -d grant_type=client_credentials) - status=$(printf '%s' "$response" | tail -n1) - body=$(printf '%s' "$response" | sed '$d') - - token=$(printf '%s' "$body" | jq -r '.access_token // empty' 2>/dev/null) - - if [ -z "$token" ]; then - # Report what Bitbucket said; the body carries no token to leak here - reason=$(printf '%s' "$body" | jq -r '.error_description // .error // empty' 2>/dev/null) - echo "::error::Bitbucket refused the token request (HTTP $status): ${reason:-no error body}" - exit 1 - fi - - echo "::add-mask::$token" - echo "TESTS_BITBUCKET_ACCESS_TOKEN=$token" >> "$GITHUB_ENV" - - name: Start Test Stack env: TESTS_GITHUB_PRIVATE_KEY: ${{ secrets.TESTS_GITHUB_PRIVATE_KEY }} TESTS_GITHUB_APP_IDENTIFIER: ${{ secrets.TESTS_GITHUB_APP_IDENTIFIER }} TESTS_GITHUB_INSTALLATION_ID: ${{ secrets.TESTS_GITHUB_INSTALLATION_ID }} + TESTS_BITBUCKET_ACCESS_TOKEN: ${{ secrets.TESTS_BITBUCKET_ACCESS_TOKEN }} TESTS_BITBUCKET_WORKSPACE: ${{ secrets.TESTS_BITBUCKET_WORKSPACE }} run: | docker compose --profile ${{ matrix.adapter }} up -d --wait --wait-timeout 900 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e73bc989..60a7234f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,43 +21,12 @@ jobs: - name: Check out the repo uses: actions/checkout@v4 - - name: Mint Bitbucket access token - if: matrix.adapter == 'bitbucket' - env: - CLIENT_ID: ${{ secrets.TESTS_BITBUCKET_CLIENT_ID }} - CLIENT_SECRET: ${{ secrets.TESTS_BITBUCKET_CLIENT_SECRET }} - run: | - # Minted per run rather than stored: an OAuth consumer is free, and the - # token it hands back lasts two hours, so nothing long-lived is kept. - # With no consumer configured the suite skips, as it does unconfigured. - if [ -z "$CLIENT_ID" ]; then - echo "No Bitbucket OAuth consumer configured; the suite will skip." - exit 0 - fi - - response=$(curl -s -w '\n%{http_code}' -X POST -u "$CLIENT_ID:$CLIENT_SECRET" \ - https://bitbucket.org/site/oauth2/access_token \ - -d grant_type=client_credentials) - status=$(printf '%s' "$response" | tail -n1) - body=$(printf '%s' "$response" | sed '$d') - - token=$(printf '%s' "$body" | jq -r '.access_token // empty' 2>/dev/null) - - if [ -z "$token" ]; then - # Report what Bitbucket said; the body carries no token to leak here - reason=$(printf '%s' "$body" | jq -r '.error_description // .error // empty' 2>/dev/null) - echo "::error::Bitbucket refused the token request (HTTP $status): ${reason:-no error body}" - exit 1 - fi - - echo "::add-mask::$token" - echo "TESTS_BITBUCKET_ACCESS_TOKEN=$token" >> "$GITHUB_ENV" - - name: Start Test Stack env: TESTS_GITHUB_PRIVATE_KEY: ${{ secrets.TESTS_GITHUB_PRIVATE_KEY }} TESTS_GITHUB_APP_IDENTIFIER: ${{ secrets.TESTS_GITHUB_APP_IDENTIFIER }} TESTS_GITHUB_INSTALLATION_ID: ${{ secrets.TESTS_GITHUB_INSTALLATION_ID }} + TESTS_BITBUCKET_ACCESS_TOKEN: ${{ secrets.TESTS_BITBUCKET_ACCESS_TOKEN }} TESTS_BITBUCKET_WORKSPACE: ${{ secrets.TESTS_BITBUCKET_WORKSPACE }} run: | docker compose --profile ${{ matrix.adapter }} up -d --wait --wait-timeout 900 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b5f74cd8..c2629b79 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,7 +102,7 @@ The `github` adapter does not require any local services — only the GitHub sec The `bitbucket` adapter runs against Bitbucket Cloud, which has no self-hostable image, so it needs credentials instead of a local service: -- `TESTS_BITBUCKET_ACCESS_TOKEN` — an OAuth 2.0 or workspace access token with read and write access to repositories, pull requests and webhooks +- `TESTS_BITBUCKET_ACCESS_TOKEN` — a credential with read and write access to repositories, pull requests and webhooks: either a bearer token (OAuth 2.0, workspace or repository) or an Atlassian account API token given as `email:token` - `TESTS_BITBUCKET_WORKSPACE` — workspace the test repositories are created in; defaults to the token owner's own workspace The workspace needs at least one project, since Bitbucket assigns every new repository to one. Without these variables the suite is skipped, the same way the `github` one is. diff --git a/src/VCS/Adapter/Git/Bitbucket.php b/src/VCS/Adapter/Git/Bitbucket.php index bb95bd3e..c7773299 100644 --- a/src/VCS/Adapter/Git/Bitbucket.php +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -132,9 +132,10 @@ public function getFileUrl(string $owner, string $repositoryName, string $refere } /** - * Bitbucket has no app installation flow; it authenticates with an OAuth 2.0 - * access token (or a workspace/repository access token), passed as - * $accessToken. $installationId, $privateKey and $appId are unused. + * Bitbucket has no app installation flow; it authenticates with the + * credential passed as $accessToken, which is either a bearer token (OAuth + * 2.0, workspace or repository) or an Atlassian account API token given as + * "email:token". $installationId, $privateKey and $appId are unused. */ public function initializeVariables(string $installationId, string $privateKey, ?string $appId = null, ?string $accessToken = null, ?string $refreshToken = null): void { @@ -212,7 +213,7 @@ public function createRepository(string $owner, string $repositoryName, bool $pr { $url = "/repositories/{$owner}/{$repositoryName}"; - $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [ + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => $this->authorizationHeader()], [ 'scm' => 'git', 'name' => $repositoryName, 'is_private' => $private, @@ -237,7 +238,7 @@ public function deleteRepository(string $owner, string $repositoryName): bool { $url = "/repositories/{$owner}/{$repositoryName}"; - $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -252,7 +253,7 @@ public function getRepository(string $owner, string $repositoryName): array { $url = "/repositories/{$owner}/{$repositoryName}"; - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -300,7 +301,7 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri $url .= '&q=' . urlencode("name~\"{$escaped}\""); } - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -400,7 +401,7 @@ private function listSource(string $owner, string $repositoryName, string $path, do { $url = $base . '?pagelen=' . self::PAGE_SIZE . "&page={$page}" . $suffix; - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -438,7 +439,7 @@ public function getRepositoryContent(string $owner, string $repositoryName, stri // which a 404 can legitimately have, so that has to be caught here // too rather than left to propagate as an uncaught fatal. try { - $metaResponse = $this->call(self::METHOD_GET, $url . '?format=meta', ['Authorization' => 'Bearer ' . $this->accessToken]); + $metaResponse = $this->call(self::METHOD_GET, $url . '?format=meta', ['Authorization' => $this->authorizationHeader()]); } catch (Exception $e) { throw new FileNotFound(); } @@ -456,7 +457,7 @@ public function getRepositoryContent(string $owner, string $repositoryName, stri // Bitbucket serves file contents raw, typed after the file extension, so // don't let the response be decoded as JSON. try { - $contentResponse = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [], false); + $contentResponse = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()], [], false); } catch (Exception $e) { throw new FileNotFound(); } @@ -523,7 +524,7 @@ public function createFile(string $owner, string $repositoryName, string $filepa self::METHOD_POST, $url, [ - 'Authorization' => 'Bearer ' . $this->accessToken, + 'Authorization' => $this->authorizationHeader(), 'content-type' => 'application/x-www-form-urlencoded', ], $payload @@ -553,7 +554,7 @@ public function createBranch(string $owner, string $repositoryName, string $newB { $url = "/repositories/{$owner}/{$repositoryName}/refs/branches"; - $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [ + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => $this->authorizationHeader()], [ 'name' => $newBranchName, 'target' => ['hash' => $oldBranchName], ]); @@ -588,7 +589,7 @@ private function listRefs(string $owner, string $repositoryName, string $type): do { $url = "/repositories/{$owner}/{$repositoryName}/refs/{$type}?pagelen=" . self::PAGE_SIZE . "&page={$page}"; - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -629,7 +630,7 @@ public function createTag(string $owner, string $repositoryName, string $tagName $payload['message'] = $message; } - $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], $payload); + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => $this->authorizationHeader()], $payload); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -644,7 +645,7 @@ public function getCommit(string $owner, string $repositoryName, string $commitH { $url = "/repositories/{$owner}/{$repositoryName}/commit/" . rawurlencode($commitHash); - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -661,7 +662,7 @@ public function getLatestCommit(string $owner, string $repositoryName, string $b { $url = "/repositories/{$owner}/{$repositoryName}/commits/" . rawurlencode($branch) . '?pagelen=1'; - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -743,7 +744,7 @@ public function updateCommitStatus(string $repositoryName, string $commitHash, s $payload['description'] = $description; } - $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], $payload); + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => $this->authorizationHeader()], $payload); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -756,7 +757,7 @@ public function getCommitStatuses(string $owner, string $repositoryName, string { $url = "/repositories/{$owner}/{$repositoryName}/commit/" . rawurlencode($commitHash) . '/statuses?pagelen=' . self::PAGE_SIZE; - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -797,7 +798,7 @@ public function createPullRequest(string $owner, string $repositoryName, string $payload['description'] = $body; } - $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], $payload); + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => $this->authorizationHeader()], $payload); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -819,7 +820,7 @@ public function getPullRequest(string $owner, string $repositoryName, int $pullR { $url = "/repositories/{$owner}/{$repositoryName}/pullrequests/{$pullRequestNumber}"; - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -837,7 +838,7 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, $query = urlencode("source.branch.name=\"{$branch}\""); $url = "/repositories/{$owner}/{$repositoryName}/pullrequests?state=OPEN&q={$query}"; - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -888,7 +889,7 @@ public function getPullRequestFiles(string $owner, string $repositoryName, int $ do { $url = "/repositories/{$owner}/{$repositoryName}/pullrequests/{$pullRequestNumber}/diffstat?pagelen=" . self::PAGE_SIZE . "&page={$page}"; - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -925,7 +926,7 @@ public function createComment(string $owner, string $repositoryName, int $pullRe { $url = "/repositories/{$owner}/{$repositoryName}/pullrequests/{$pullRequestNumber}/comments"; - $response = $this->call(self::METHOD_POST, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [ + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => $this->authorizationHeader()], [ 'content' => ['raw' => $comment], ]); @@ -954,7 +955,7 @@ public function getComment(string $owner, string $repositoryName, string $commen [$pullRequestNumber, $id] = $parts; $url = "/repositories/{$owner}/{$repositoryName}/pullrequests/{$pullRequestNumber}/comments/{$id}"; - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()]); return $response['body']['content']['raw'] ?? ''; } @@ -969,7 +970,7 @@ public function updateComment(string $owner, string $repositoryName, string $com [$pullRequestNumber, $id] = $parts; $url = "/repositories/{$owner}/{$repositoryName}/pullrequests/{$pullRequestNumber}/comments/{$id}"; - $response = $this->call(self::METHOD_PUT, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [ + $response = $this->call(self::METHOD_PUT, $url, ['Authorization' => $this->authorizationHeader()], [ 'content' => ['raw' => $comment], ]); @@ -1002,7 +1003,7 @@ public function createWebhook(string $owner, string $repositoryName, string $url $payload['secret'] = $secret; } - $response = $this->call(self::METHOD_POST, $apiUrl, ['Authorization' => 'Bearer ' . $this->accessToken], $payload); + $response = $this->call(self::METHOD_POST, $apiUrl, ['Authorization' => $this->authorizationHeader()], $payload); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -1052,7 +1053,7 @@ public function getUser(string $username): array // accounts still resolve by name. $url = '/users/' . rawurlencode($username); - $response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -1080,7 +1081,7 @@ public function getUser(string $username): array */ protected function getAuthenticatedUser(): array { - $response = $this->call(self::METHOD_GET, '/user', ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, '/user', ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -1101,7 +1102,7 @@ protected function getAuthenticatedUser(): array */ public function getOwnerName(string $installationId, ?int $repositoryId = null): string { - $response = $this->call(self::METHOD_GET, '/user/workspaces', ['Authorization' => 'Bearer ' . $this->accessToken]); + $response = $this->call(self::METHOD_GET, '/user/workspaces', ['Authorization' => $this->authorizationHeader()]); $responseHeaders = $response['headers'] ?? []; $statusCode = $responseHeaders['status-code'] ?? 0; @@ -1120,8 +1121,26 @@ public function getOwnerName(string $installationId, ?int $repositoryId = null): } /** - * $bitbucketUrl with the access token embedded as HTTP Basic userinfo - * (https://x-token-auth:{token}@bitbucket.org). + * Authorization header for the credential this adapter was given. + * + * An Atlassian account API token authenticates as "email:token" over HTTP + * Basic; an OAuth 2.0 or workspace token is a Bearer credential. The colon + * an email:token pair always carries, and a token alone never does, tells + * the two apart. + */ + private function authorizationHeader(): string + { + if (\strpos($this->accessToken, ':') !== false) { + return 'Basic ' . \base64_encode($this->accessToken); + } + + return 'Bearer ' . $this->accessToken; + } + + /** + * $bitbucketUrl with the credential embedded as HTTP Basic userinfo, which + * an email:token pair already is; a bare token needs the x-token-auth + * username Bitbucket pairs it with. */ private function authenticatedBitbucketUrl(): string { @@ -1129,7 +1148,11 @@ private function authenticatedBitbucketUrl(): string return $this->bitbucketUrl; } - return str_replace('://', '://x-token-auth:' . urlencode($this->accessToken) . '@', $this->bitbucketUrl); + $userinfo = \strpos($this->accessToken, ':') !== false + ? \implode(':', \array_map('urlencode', \explode(':', $this->accessToken, 2))) + : 'x-token-auth:' . \urlencode($this->accessToken); + + return str_replace('://', '://' . $userinfo . '@', $this->bitbucketUrl); } /** From a5278be95a03b35311c90b823f255c6339b19382 Mon Sep 17 00:00:00 2001 From: harsh mahajan Date: Mon, 3 Aug 2026 13:57:22 +0530 Subject: [PATCH 34/34] ci: report which auth scheme the Bitbucket credential selects A bare token and an email:token pair fail very differently, and the 401 they produce looks the same from the outside. Reports the shape -- never the value -- so a misformed secret is obvious rather than inferred. --- .github/workflows/tests-external.yml | 14 ++++++++++++++ .github/workflows/tests.yml | 14 ++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/.github/workflows/tests-external.yml b/.github/workflows/tests-external.yml index 7e21070e..320c372c 100644 --- a/.github/workflows/tests-external.yml +++ b/.github/workflows/tests-external.yml @@ -25,6 +25,20 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} + - name: Check Bitbucket credential shape + if: matrix.adapter == 'bitbucket' + env: + TOKEN: ${{ secrets.TESTS_BITBUCKET_ACCESS_TOKEN }} + run: | + # Reports which auth scheme the adapter will pick, never the value + if [ -z "$TOKEN" ]; then + echo "TESTS_BITBUCKET_ACCESS_TOKEN is not set" + elif printf '%s' "$TOKEN" | grep -q ':'; then + echo "credential is email:token -> Basic (${#TOKEN} chars)" + else + echo "credential is a bare token -> Bearer (${#TOKEN} chars)" + fi + - name: Start Test Stack env: TESTS_GITHUB_PRIVATE_KEY: ${{ secrets.TESTS_GITHUB_PRIVATE_KEY }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 60a7234f..59266529 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,6 +21,20 @@ jobs: - name: Check out the repo uses: actions/checkout@v4 + - name: Check Bitbucket credential shape + if: matrix.adapter == 'bitbucket' + env: + TOKEN: ${{ secrets.TESTS_BITBUCKET_ACCESS_TOKEN }} + run: | + # Reports which auth scheme the adapter will pick, never the value + if [ -z "$TOKEN" ]; then + echo "TESTS_BITBUCKET_ACCESS_TOKEN is not set" + elif printf '%s' "$TOKEN" | grep -q ':'; then + echo "credential is email:token -> Basic (${#TOKEN} chars)" + else + echo "credential is a bare token -> Bearer (${#TOKEN} chars)" + fi + - name: Start Test Stack env: TESTS_GITHUB_PRIVATE_KEY: ${{ secrets.TESTS_GITHUB_PRIVATE_KEY }}