diff --git a/.github/workflows/tests-external.yml b/.github/workflows/tests-external.yml index 022b7779..320c372c 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 @@ -25,11 +25,27 @@ 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 }} 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..59266529 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,17 +15,33 @@ 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 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 }} 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..c2629b79 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` — 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. + ## 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/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/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/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.php b/src/VCS/Adapter.php index 367c068d..76c72f86 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -224,6 +224,26 @@ 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 + { + $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]; + } + /** * HTTP header name carrying the webhook event type (e.g. 'x-github-event'). */ 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 new file mode 100644 index 00000000..c7773299 --- /dev/null +++ b/src/VCS/Adapter/Git/Bitbucket.php @@ -0,0 +1,1401 @@ + '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', + ]; + + 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; + } + + /** + * Moves only the API host; the browser-facing host stays on bitbucket.org. + */ + public function setEndpoint(string $endpoint): void + { + $this->endpoint = rtrim($endpoint, '/'); + } + + 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 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 + { + if (!empty($accessToken)) { + $this->accessToken = $accessToken; + + 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; + } + + /** + * 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'] ?? ''); + } + + /** + * 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 + { + $repository['id'] = (string) ($repository['full_name'] ?? ''); + $repository['private'] = ($repository['is_private'] ?? false) === true; + $repository['pushed_at'] = $repository['updated_on'] ?? ''; + + if (empty($repository['workspace']['slug'])) { + $repository['workspace'] = ['slug' => $this->workspaceSlugOf($repository)]; + } + + return $repository; + } + + public function createRepository(string $owner, string $repositoryName, bool $private): array + { + $url = "/repositories/{$owner}/{$repositoryName}"; + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => $this->authorizationHeader()], [ + 'scm' => 'git', + 'name' => $repositoryName, + 'is_private' => $private, + ]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + $error = $response['body']['error']['message'] ?? ''; + throw new Exception( + "Creating repository {$repositoryName} failed with status code {$statusCode}" . ($error !== '' ? ": {$error}" : ''), + $statusCode + ); + } + + $responseBody = $response['body'] ?? []; + + return $this->normalizeRepository(is_array($responseBody) ? $responseBody : []); + } + + public function deleteRepository(string $owner, string $repositoryName): bool + { + $url = "/repositories/{$owner}/{$repositoryName}"; + + $response = $this->call(self::METHOD_DELETE, $url, ['Authorization' => $this->authorizationHeader()]); + + $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' => $this->authorizationHeader()]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new RepositoryNotFound("Repository not found"); + } + + $responseBody = $response['body'] ?? []; + + return $this->normalizeRepository(is_array($responseBody) ? $responseBody : []); + } + + public function getRepositoryName(string $repositoryId): string + { + // $repositoryId is the "workspace/slug" id normalizeRepository() mints + if (strpos($repositoryId, '/') === false) { + throw new RepositoryNotFound("Repository {$repositoryId} not found"); + } + + [$workspace, $slug] = explode('/', $repositoryId, 2); + + // `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 + { + 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' => $this->authorizationHeader()]); + + $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) { + $repository = $this->normalizeRepository(is_array($repository) ? $repository : []); + $repositories[] = [ + 'id' => $repository['id'], + 'name' => $repository['name'] ?? '', + 'description' => $repository['description'] ?? '', + 'private' => $repository['private'], + 'pushed_at' => $repository['pushed_at'], + ]; + } + + // `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; + } + + /** + * 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 + { + if (empty($ref)) { + $ref = $this->mainBranchName($owner, $repositoryName); + + 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; + } + + /** + * 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 { + $base = $this->sourceUrl($owner, $repositoryName, $path, $ref); + } catch (Exception $e) { + return []; + } + + $items = []; + $page = 1; + do { + $url = $base . '?pagelen=' . self::PAGE_SIZE . "&page={$page}" . $suffix; + + $response = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()]); + + $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 { + $url = $this->sourceUrl($owner, $repositoryName, $path, $ref); + } catch (Exception $e) { + throw new FileNotFound(); + } + + // 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' => $this->authorizationHeader()]); + } catch (Exception $e) { + throw new FileNotFound(); + } + + $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. + try { + $contentResponse = $this->call(self::METHOD_GET, $url, ['Authorization' => $this->authorizationHeader()], [], false); + } catch (Exception $e) { + throw new FileNotFound(); + } + + $contentHeaders = $contentResponse['headers'] ?? []; + if (($contentHeaders['status-code'] ?? 0) !== 200) { + throw new FileNotFound(); + } + + $content = $contentResponse['body'] ?? ''; + $content = is_string($content) ? $content : ''; + + return [ + // 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, + ]; + } + + /** + * 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 + { + try { + $repository = $this->getRepository($owner, $repositoryName); + } catch (RepositoryNotFound $e) { + return []; + } + + $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->mainBranchName($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, + '/' . $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, + [ + 'Authorization' => $this->authorizationHeader(), + '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, + ]; + } + + 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' => $this->authorizationHeader()], [ + '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' => $this->authorizationHeader()]); + + $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' => $this->authorizationHeader()], $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' => $this->authorizationHeader()]); + + $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' => $this->authorizationHeader()]); + + $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]); + } + + /** + * 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. + * + * @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'] : []; + + $name = $this->authorNameOf($author); + + 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 $commitHash, string $owner, string $state, string $description = '', string $target_url = '', string $context = ''): void + { + $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. + $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, $commitHash) : $target_url, + ]; + + if (!empty($description)) { + $payload['description'] = $description; + } + + $response = $this->call(self::METHOD_POST, $url, ['Authorization' => $this->authorizationHeader()], $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' => $this->authorizationHeader()]); + + $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' => $this->authorizationHeader()], $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' => $this->authorizationHeader()]); + + $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' => $this->authorizationHeader()]); + + $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' => $this->authorizationHeader()]); + + $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' => $this->authorizationHeader()], [ + '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' => $this->authorizationHeader()]); + + 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' => $this->authorizationHeader()], [ + '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; + } + + /** + * @param array $events Event names, either this library's ('push', + * 'pull_request') or Bitbucket's own keys + * (e.g. 'repo:push') + */ + public function createWebhook(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' => $this->authorizationHeader()], $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); + } + + $uuid = $response['body']['uuid'] ?? null; + if ($uuid === null || $uuid === '') { + throw new Exception('Webhook created but response did not include a uuid'); + } + + return (string) $uuid; + } + + /** + * 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' => $this->authorizationHeader()]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get user: HTTP {$statusCode}", $statusCode); + } + + $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. + $responseBody['id'] = $responseBody['uuid']; + $responseBody['username'] = $responseBody['username'] ?? ($responseBody['nickname'] ?? ''); + + return $responseBody; + } + + /** + * Account the access token belongs to. + * + * @return array + */ + protected function getAuthenticatedUser(): array + { + $response = $this->call(self::METHOD_GET, '/user', ['Authorization' => $this->authorizationHeader()]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get current user: HTTP {$statusCode}", $statusCode); + } + + $responseBody = $response['body'] ?? []; + + return is_array($responseBody) ? $responseBody : []; + } + + /** + * $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 + { + $response = $this->call(self::METHOD_GET, '/user/workspaces', ['Authorization' => $this->authorizationHeader()]); + + $responseHeaders = $response['headers'] ?? []; + $statusCode = $responseHeaders['status-code'] ?? 0; + if ($statusCode < 400) { + $responseBody = $response['body'] ?? []; + $values = is_array($responseBody) ? ($responseBody['values'] ?? []) : []; + $slug = $values[0]['slug'] ?? ''; + if (!empty($slug)) { + return (string) $slug; + } + } + + $user = $this->getAuthenticatedUser(); + + return (string) ($user['username'] ?? ($user['nickname'] ?? '')); + } + + /** + * 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 + { + if (empty($this->accessToken)) { + return $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); + } + + /** + * @link https://support.atlassian.com/bitbucket-cloud/kb/how-to-download-repositories-using-the-api/ + * + * 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 + { + $extension = match ($format) { + 'tarball' => 'tar.gz', + 'zipball' => 'zip', + default => throw new Exception("Invalid archive format: {$format}. Use 'tarball' or 'zipball'."), + }; + + $baseUrl = $this->authenticatedBitbucketUrl(); + + // 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/{$encodedRef}.{$extension}"; + } + + public function generateCloneCommand(string $owner, string $repositoryName, string $version, string $versionType, string $directory, string $rootDirectory): string + { + if (empty($rootDirectory) || $rootDirectory === '/') { + $rootDirectory = '*'; + } + + $cloneUrl = escapeshellarg("{$this->authenticatedBitbucketUrl()}/{$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); + } + + /** + * 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] ?? []; + } + + /** + * 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 (!is_array($payloadArray)) { + throw new Exception("Invalid payload."); + } + + $repository = is_array($payloadArray['repository'] ?? null) ? $payloadArray['repository'] : []; + $actor = is_array($payloadArray['actor'] ?? null) ? $payloadArray['actor'] : []; + + switch ($event) { + case 'repo:push': + $changes = $payloadArray['push']['changes'] ?? []; + + $events = []; + foreach ($changes as $change) { + if (!is_array($change) || !$this->isBranchChange($change)) { + continue; + } + + $events[] = $this->parsePushChange($change, $repository, $actor); + } + + return $events; + + case 'pullrequest:created': + case 'pullrequest:updated': + case 'pullrequest:fulfilled': + case 'pullrequest:rejected': + 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 + { + $type = $change['new']['type'] ?? ($change['old']['type'] ?? 'branch'); + + return \in_array($type, ['branch', 'named_branch'], true); + } + + /** + * @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 = $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'] : []; + + // 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 = $this->authorNameOf($author); + + $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' => (string) ($repository['full_name'] ?? ''), + '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 = $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'] : []; + $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' => (string) ($repository['full_name'] ?? ''), + '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. + */ + 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..d3a3dfde --- /dev/null +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -0,0 +1,256 @@ +markTestSkipped('Bitbucket access token not configured'); + } + + $adapter = new Bitbucket(new Cache(new None())); + $adapter->initializeVariables( + installationId: '', + privateKey: '', + appId: '', + accessToken: static::$accessToken, + refreshToken: '' + ); + + 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; + } + + /** + * @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 + { + 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]], + ]; + } + + /** + * @return array + */ + private function eventActor(): array + { + return [ + 'display_name' => 'Tester', + 'links' => [ + 'html' => ['href' => 'https://bitbucket.org/tester'], + 'avatar' => ['href' => 'https://bitbucket.org/account/tester/avatar/'], + ], + ]; + } + + /** + * 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 = 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, (string) json_encode($payload)); + + $this->assertSame('Linked User', $result['headCommitAuthorName']); + $this->assertSame(static::EVENT_AUTHOR_EMAIL, $result['headCommitAuthorEmail']); + } + + /** + * 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 = (string) json_encode([ + 'actor' => $this->eventActor(), + 'repository' => $this->eventRepository(), + '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], + ], + ], + ]); + + $events = $this->vcsAdapter->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')); + $this->assertTrue($events[1]['branchCreated']); + + // getEvent() reports the first of them + $this->assertSame($events[0], $this->vcsAdapter->getEvent(static::$pushEventName, $payload)); + + // 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([], $this->vcsAdapter->getEvent(static::$pushEventName, $tagsOnly)); + } + + 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}'"); + } + } +} diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 032585ff..3f27c2ce 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,12 @@ abstract class Base extends TestCase protected static bool $supportsNamespaceListing = 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 +284,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 +558,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 +1135,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); } @@ -1631,7 +1650,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); @@ -1674,6 +1703,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,7 +2348,7 @@ public function testGetEventPush(): void ); $this->assertSame(static::$defaultBranch, $result['branch']); - $this->assertSame(self::EVENT_REPOSITORY_ID, $result['repositoryId']); + $this->assertSame(static::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']); @@ -2330,7 +2360,10 @@ public function testGetEventPush(): void $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 @@ -2362,7 +2395,7 @@ public function testGetEventPullRequest(): void $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(static::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']);