From 28d9397713649ee47a27cf83b87300f8793cb4ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 18 Aug 2026 14:15:05 +0200 Subject: [PATCH 01/14] feat: add Origin (Cursor) adapter Adds a Git adapter for Origin, Cursor's code hosting platform: - EdDSA (Ed25519) app JWTs signed with ext-sodium, exchanged for short-lived installation access tokens - Content writes (files, branches, tags) over Git HTTPS, since the partner API is read-only for repository contents - Check runs mapped onto Origin's upsert-by-key model - Ed25519 webhook signature validation and delivery-envelope parsing, including multi-ref push deliveries - Full partner API surface: pull request update/merge/listing, reviews, commit listing/files/compare, batch contents, batch check-run upsert, app/installation/webhook-delivery management, rate limit, mirror sync - Repository deletion via the Cursor web API (not in the partner API) Base test additions: capability flags for providers without repository deletion, visibility flags, or archive downloads. Empty JSON POST bodies now encode as {} so proto3-JSON endpoints accept them. Co-Authored-By: Claude Fable 5 --- README.md | 5 +- docker-compose.yml | 3 + phpunit.xml | 3 + src/VCS/Adapter.php | 4 +- src/VCS/Adapter/Git/Origin.php | 2556 ++++++++++++++++++++++++++++++ tests/VCS/Adapter/OriginTest.php | 629 ++++++++ tests/VCS/Base.php | 68 +- 7 files changed, 3251 insertions(+), 17 deletions(-) create mode 100644 src/VCS/Adapter/Git/Origin.php create mode 100644 tests/VCS/Adapter/OriginTest.php diff --git a/README.md b/README.md index d7cc8926..fb63d763 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,9 @@ VCS Adapters: | Adapter | Status | |---------|---------| | GitHub | ✅ | -| GitLab | | -| Bitbucket | | +| Origin (Cursor) | ✅ | +| GitLab | ✅ | +| Bitbucket | ✅ | | Azure DevOps | | `✅ - supported, 🛠 - work in progress` diff --git a/docker-compose.yml b/docker-compose.yml index 0fb4932c..ba7f7fe0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,9 @@ services: - TESTS_GITHUB_INSTALLATION_ID - TESTS_BITBUCKET_ACCESS_TOKEN - TESTS_BITBUCKET_WORKSPACE + - TESTS_ORIGIN_PRIVATE_KEY + - TESTS_ORIGIN_APP_IDENTIFIER + - TESTS_ORIGIN_INSTALLATION_ID - 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 45b40fa2..0721aaa6 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -27,5 +27,8 @@ ./tests/VCS/Adapter/BitbucketTest.php + + ./tests/VCS/Adapter/OriginTest.php + \ No newline at end of file diff --git a/src/VCS/Adapter.php b/src/VCS/Adapter.php index 8411ee8e..e5406275 100644 --- a/src/VCS/Adapter.php +++ b/src/VCS/Adapter.php @@ -492,7 +492,9 @@ protected function call(string $method, string $path = '', array $headers = [], switch ($headers['content-type']) { case 'application/json': - $query = json_encode($params); + // An empty body must encode as an object - some APIs (e.g. + // Origin's proto3-JSON endpoints) reject a bare array + $query = $params === [] ? '{}' : json_encode($params); break; case 'multipart/form-data': diff --git a/src/VCS/Adapter/Git/Origin.php b/src/VCS/Adapter/Git/Origin.php new file mode 100644 index 00000000..e8c6b430 --- /dev/null +++ b/src/VCS/Adapter/Git/Origin.php @@ -0,0 +1,2556 @@ + 'opened', + 'pull_request.head_ref.pushed' => 'synchronize', + 'pull_request.base_ref.updated' => 'edited', + 'pull_request.metadata.updated' => 'edited', + 'pull_request.closed' => 'closed', + 'pull_request.merged' => 'closed', + 'pull_request.reopened' => 'reopened', + 'pull_request.published' => 'ready_for_review', + ]; + + protected string $endpoint = 'https://api.cursor.com/v1/origin'; + + /** + * The Cursor web app's own API. Repository deletion is not part of the + * documented partner API but is available here. + */ + protected string $webApiEndpoint = 'https://cursor.com/api/origin'; + + /** + * Browser-facing host. origin.cursor.com redirects here. + */ + protected string $webEndpoint = 'https://cursor.com/codebase'; + + /** + * Git HTTPS host, also the canonical clone URL host. + */ + protected string $gitEndpoint = 'https://origin.cursor.com'; + + protected string $accessToken = ''; + + protected string $jwtToken = ''; + + protected string $installationId = ''; + + protected Cache $cache; + + /** + * Global Headers + * + * @var array + */ + protected $headers = ['content-type' => 'application/json']; + + public function __construct(Cache $cache) + { + $this->cache = $cache; + } + + /** + * Get Adapter Name + */ + public function getName(): string + { + return 'origin'; + } + + /** + * Origin initialisation with access token generation. + * + * @param string $installationId Origin installation id (i_...) + * @param string $privateKey Ed25519 private key, PKCS#8 PEM as generated by + * `openssl genpkey -algorithm ED25519` + * @param string|null $appId Origin app id (app_...), used as both JWT issuer and key id + */ + public function initializeVariables(string $installationId, string $privateKey, ?string $appId = null, ?string $accessToken = null, ?string $refreshToken = null): void + { + $this->installationId = $installationId; + + // Cache for 1 minute less than the JWT expiry so we refresh before the token actually expires. + $response = $this->cache->load('origin-' . $installationId, self::APP_JWT_EXPIRY - 60); + if ($response == false || !\is_string($response)) { + $this->generateAccessToken($privateKey, $appId); + + $tokens = \json_encode([ + 'jwtToken' => $this->jwtToken, + 'accessToken' => $this->accessToken, + ]) ?: '{}'; + + $this->cache->save('origin-' . $installationId, $tokens); + } else { + $parsed = \json_decode($response, true); + $parsed = \is_array($parsed) ? $parsed : []; + $this->jwtToken = \strval($parsed['jwtToken'] ?? ''); + $this->accessToken = \strval($parsed['accessToken'] ?? ''); + } + } + + /** + * Generate Access Token + * + * Signs an EdDSA app JWT and exchanges it for an installation access + * token (oit_...). + */ + protected function generateAccessToken(string $privateKey, ?string $appId): void + { + if (empty($appId)) { + throw new Exception('Origin requires the app id to sign the app JWT.'); + } + + $this->jwtToken = $this->generateAppJwt($privateKey, $appId); + + $response = $this->call( + self::METHOD_POST, + '/app/installations/' . $this->installationId . '/access_tokens', + ['Authorization' => 'Bearer ' . $this->jwtToken], + ['scopes' => [], 'repositoryIds' => []] // empty arrays inherit the full installation grant + ); + + $responseBody = $response['body'] ?? []; + $statusCode = $response['headers']['status-code'] ?? 0; + if (!\is_array($responseBody) || !\array_key_exists('token', $responseBody)) { + $safeBody = \is_array($responseBody) ? \json_encode(\array_intersect_key($responseBody, \array_flip(['code', 'message']))) : ''; + throw new Exception('Failed to retrieve access token from Origin API. Status: ' . $statusCode . '. Response: ' . $safeBody, (int) $statusCode); + } + + $this->accessToken = \strval($responseBody['token'] ?? ''); + } + + /** + * Builds the EdDSA app JWT Origin expects: Ed25519 signature, kid and iss + * both set to the app id, audience fixed to 'origin-apps'. + */ + protected function generateAppJwt(string $privateKey, string $appId): string + { + $secretKey = $this->ed25519SecretKey($privateKey); + + $iat = \time(); + $header = ['alg' => 'EdDSA', 'kid' => $appId, 'typ' => 'JWT']; + $claims = [ + 'iss' => $appId, + 'aud' => 'origin-apps', + 'iat' => $iat, + 'exp' => $iat + self::APP_JWT_EXPIRY, + ]; + + $signingInput = $this->base64UrlEncode(\json_encode($header) ?: '') + . '.' + . $this->base64UrlEncode(\json_encode($claims) ?: ''); + + $signature = \sodium_crypto_sign_detached($signingInput, $secretKey); + + return $signingInput . '.' . $this->base64UrlEncode($signature); + } + + /** + * Resolves the configured private key to a libsodium Ed25519 secret key. + * + * Accepts the PKCS#8 PEM the docs tell app authors to generate, a raw + * base64-encoded 32-byte seed, or a base64-encoded 64-byte libsodium + * secret key. + * + * @return non-empty-string + */ + protected function ed25519SecretKey(string $privateKey): string + { + $privateKey = \trim($privateKey); + + if (\str_contains($privateKey, '-----BEGIN')) { + $body = \preg_replace('/-----[A-Z ]+-----|\s+/', '', $privateKey) ?? ''; + $der = \base64_decode($body, true); + if ($der === false) { + throw new Exception('Failed to decode the Origin private key PEM.'); + } + + // In PKCS#8 the Ed25519 seed is the 32 bytes after the + // OCTET STRING wrapper (04 22 04 20). + $marker = "\x04\x22\x04\x20"; + $offset = \strpos($der, $marker); + $seed = $offset !== false ? \substr($der, $offset + 4, 32) : ''; + if (\strlen($seed) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) { + throw new Exception('The Origin private key is not an Ed25519 PKCS#8 key.'); + } + } else { + $decoded = \base64_decode(\strtr($privateKey, '-_', '+/'), true); + if ($decoded === false) { + throw new Exception('Failed to decode the Origin private key.'); + } + + if (\strlen($decoded) === SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) { + return $decoded; + } + + if (\strlen($decoded) !== SODIUM_CRYPTO_SIGN_SEEDBYTES) { + throw new Exception('The Origin private key has an unexpected length.'); + } + + $seed = $decoded; + } + + return \sodium_crypto_sign_secretkey(\sodium_crypto_sign_seed_keypair($seed)); + } + + protected function base64UrlEncode(string $data): string + { + return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '='); + } + + /** + * Get user + * + * @return array + */ + public function getUser(string $username): array + { + throw new Exception('getUser() is not supported by Origin'); + } + + /** + * Get owner name of the Origin installation + * + * @param string $installationId Origin installation id + * @param int|null $repositoryId Not used by Origin (parameter exists for adapter compatibility) + * @return string Owner slug + */ + public function getOwnerName(string $installationId, ?int $repositoryId = null): string + { + $installation = $this->getInstallation($installationId); + + $target = \is_array($installation['target'] ?? null) ? $installation['target'] : []; + if (!\array_key_exists('slug', $target)) { + throw new Exception('Owner name retrieval response is missing the installation target slug.'); + } + + return \strval($target['slug']); + } + + /** + * Determines whether the installation has access to all repositories or specific repositories + */ + public function hasAccessToAllRepositories(): bool + { + $installation = $this->getInstallation($this->installationId); + + return ($installation['repoSelectionMode'] ?? '') === 'all'; + } + + /** + * @return array + */ + protected function getInstallation(string $installationId): array + { + $response = $this->call( + self::METHOD_GET, + '/app/installations/' . $installationId, + ['Authorization' => "Bearer {$this->jwtToken}"] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get installation {$installationId}: HTTP {$statusCode}", (int) $statusCode); + } + + return \is_array($response['body'] ?? null) ? $response['body'] : []; + } + + /** + * Search repositories accessible to the installation owner. + * + * Origin paginates with opaque tokens and reports no totals, so every + * matching page is collected once and page/per_page are applied locally. + * + * @return array{items: array, total: int} + */ + public function searchRepositories(string $owner, int $page, int $per_page, string $search = ''): array + { + $repositories = []; + $pageToken = ''; + + do { + $params = ['pageSize' => 100]; + if ($search !== '') { + $params['filter'] = $search; // case-insensitive substring filter + } + if ($pageToken !== '') { + $params['pageToken'] = $pageToken; + } + + $response = $this->call(self::METHOD_GET, '/repos/' . \rawurlencode($owner), ['Authorization' => "Bearer {$this->accessToken}"], $params); + + $statusCode = $response['headers']['status-code'] ?? 0; + + // The docs reserve owner-wide listing ("Current limitations"), so + // a denial falls back to walking the installation's repositories. + // An owner that does not exist looks the same from outside; the + // fallback answers both with what the installation can see. + if ($statusCode === 403 || $statusCode === 404) { + return $this->searchInstallationRepositories($owner, $page, $per_page, $search); + } + + if ($statusCode >= 400) { + throw new Exception("Failed to search repositories: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + $repositories = \array_merge($repositories, \is_array($responseBody['repositories'] ?? null) ? $responseBody['repositories'] : []); + $pageToken = \strval($responseBody['nextPageToken'] ?? ''); + } while ($pageToken !== ''); + + return [ + 'items' => \array_slice($repositories, ($page - 1) * $per_page, $per_page), + 'total' => \count($repositories), + ]; + } + + /** + * Search fallback over the repositories the installation can access, + * filtered to the requested owner and name substring. + * + * @return array{items: array, total: int} + */ + protected function searchInstallationRepositories(string $owner, int $page, int $per_page, string $search): array + { + $repositories = []; + foreach ($this->installationRepositories() as $repository) { + $repositoryOwner = \is_array($repository['owner'] ?? null) ? $repository['owner'] : []; + if (\strtolower(\strval($repositoryOwner['slug'] ?? '')) !== \strtolower($owner)) { + continue; + } + if ($search !== '' && \stripos(\strval($repository['name'] ?? ''), $search) === false) { + continue; + } + + $repositories[] = $repository; + } + + return [ + 'items' => \array_slice($repositories, ($page - 1) * $per_page, $per_page), + 'total' => \count($repositories), + ]; + } + + /** + * Get repository for the installation + * + * @return array + */ + public function getInstallationRepository(string $repositoryName): array + { + foreach ($this->installationRepositories() as $repository) { + if (\strtolower(\strval($repository['name'] ?? '')) === \strtolower($repositoryName)) { + return $repository; + } + } + + throw new RepositoryNotFound('Repository not found.'); + } + + /** + * Fetches repository name using repository id + */ + public function getRepositoryName(string $repositoryId): string + { + foreach ($this->installationRepositories() as $repository) { + if (\strval($repository['id'] ?? '') === $repositoryId) { + return \strval($repository['name'] ?? ''); + } + } + + throw new RepositoryNotFound('Repository not found.'); + } + + /** + * Walks every repository the installation can access. Origin has no + * repository-by-id endpoint, so lookups scan this list. + * + * @return \Generator> + */ + protected function installationRepositories(): \Generator + { + $pageToken = ''; + $fetched = 0; + $maxRepositories = 1000; + + do { + $params = ['pageSize' => 100]; + if ($pageToken !== '') { + $params['pageToken'] = $pageToken; + } + + $response = $this->call(self::METHOD_GET, '/installation/repos', ['Authorization' => "Bearer {$this->accessToken}"], $params); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to list installation repositories: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + $repositories = \is_array($responseBody['repositories'] ?? null) ? $responseBody['repositories'] : []; + + foreach ($repositories as $repository) { + if (\is_array($repository)) { + yield $repository; + } + } + + $fetched += \count($repositories); + $pageToken = \strval($responseBody['nextPageToken'] ?? ''); + } while ($pageToken !== '' && $fetched < $maxRepositories); + } + + /** + * Get Origin repository + * + * @return array + */ + public function getRepository(string $owner, string $repositoryName): array + { + $response = $this->call( + self::METHOD_GET, + '/repos/' . \rawurlencode($owner) . '/' . \rawurlencode($repositoryName), + ['Authorization' => "Bearer {$this->accessToken}"] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode === 403 || $statusCode === 404) { + throw new RepositoryNotFound('Repository not found.'); + } + if ($statusCode >= 400) { + throw new Exception("Failed to get repository {$repositoryName}: HTTP {$statusCode}", (int) $statusCode); + } + + return \is_array($response['body'] ?? null) ? $response['body'] : []; + } + + /** + * Create new repository + * + * Origin has no repository visibility flag - who can see a repository + * follows from the owning user or team workspace - so $private is ignored. + * + * @return array Details of new repository + */ + public function createRepository(string $owner, string $repositoryName, bool $private): array + { + $response = $this->call( + self::METHOD_POST, + '/repos/' . \rawurlencode($owner), + ['Authorization' => "Bearer {$this->accessToken}"], + ['name' => $repositoryName] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Creating repository {$repositoryName} failed with status code {$statusCode}", (int) $statusCode); + } + + return \is_array($response['body'] ?? null) ? $response['body'] : []; + } + + /** + * Delete repository + * + * The documented partner API has no deletion endpoint; this rides the + * Cursor web app's own API instead, which names the owner `org`. + */ + public function deleteRepository(string $owner, string $repositoryName): bool + { + $endpoint = $this->endpoint; + $this->endpoint = $this->webApiEndpoint; + + try { + $response = $this->call( + self::METHOD_POST, + '/delete-repo', + [ + 'Authorization' => "Bearer {$this->accessToken}", + // The web API refuses cross-origin state changes outright + 'origin' => 'https://cursor.com', + ], + ['identifier' => ['org' => $owner, 'name' => $repositoryName]] + ); + } finally { + $this->endpoint = $endpoint; + } + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Deleting repository {$repositoryName} failed with status code {$statusCode}", (int) $statusCode); + } + + return true; + } + + /** + * Get latest opened pull request with specific base branch + * + * @return array + */ + public function getPullRequestFromBranch(string $owner, string $repositoryName, string $branch): array + { + $response = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/pulls', + ['Authorization' => "Bearer {$this->accessToken}"], + ['head' => $branch, 'state' => 'open', 'pageSize' => 1] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to list pull requests: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + $pullRequests = \is_array($responseBody['pullRequests'] ?? null) ? $responseBody['pullRequests'] : []; + + $pullRequest = $pullRequests[0] ?? []; + + return \is_array($pullRequest) && !empty($pullRequest) ? $this->normalizePullRequest($pullRequest) : []; + } + + /** + * Get Pull Request + * + * @return array The retrieved pull request + */ + public function getPullRequest(string $owner, string $repositoryName, int $pullRequestNumber): array + { + $response = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/pulls/' . $pullRequestNumber, + ['Authorization' => "Bearer {$this->accessToken}"] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get pull request: HTTP {$statusCode}", (int) $statusCode); + } + + return $this->normalizePullRequest(\is_array($response['body'] ?? null) ? $response['body'] : []); + } + + /** + * Create a pull request + * + * @return array Created PR details + */ + public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array + { + $response = $this->call( + self::METHOD_POST, + $this->repositoryPath($owner, $repositoryName) . '/pulls', + ['Authorization' => "Bearer {$this->accessToken}"], + [ + 'title' => $title, + 'head' => $head, + 'base' => $base, + 'body' => $body, + ] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to create pull request: HTTP {$statusCode}", (int) $statusCode); + } + + return $this->normalizePullRequest(\is_array($response['body'] ?? null) ? $response['body'] : []); + } + + /** + * Origin encodes pull request numbers as JSON strings and may report refs + * fully qualified; normalize both to the shapes the contract promises. + * + * @param array $pullRequest + * @return array + */ + protected function normalizePullRequest(array $pullRequest): array + { + if (\array_key_exists('number', $pullRequest)) { + $pullRequest['number'] = (int) $pullRequest['number']; + } + + foreach (['head', 'base'] as $side) { + if (\is_array($pullRequest[$side] ?? null) && \is_string($pullRequest[$side]['ref'] ?? null)) { + $pullRequest[$side]['ref'] = $this->shortBranchName($pullRequest[$side]['ref']); + } + } + + return $pullRequest; + } + + protected function shortBranchName(string $ref): string + { + return \str_starts_with($ref, 'refs/heads/') ? \substr($ref, \strlen('refs/heads/')) : $ref; + } + + /** + * Get files changed in a pull request + * + * @return array List of files changed in the pull request + */ + public function getPullRequestFiles(string $owner, string $repositoryName, int $pullRequestNumber): array + { + return $this->collectPages( + $this->repositoryPath($owner, $repositoryName) . '/pulls/' . $pullRequestNumber . '/files', + 'files' + ); + } + + /** + * Add Comment to Pull Request + */ + public function createComment(string $owner, string $repositoryName, int $pullRequestNumber, string $comment): string + { + $response = $this->call( + self::METHOD_POST, + $this->repositoryPath($owner, $repositoryName) . '/pulls/' . $pullRequestNumber . '/comments', + ['Authorization' => "Bearer {$this->accessToken}"], + ['body' => $comment] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to create comment: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + if (!\array_key_exists('id', $responseBody)) { + throw new Exception('Comment creation response is missing comment ID.'); + } + + return \strval($responseBody['id']); + } + + /** + * Get Comment of Pull Request + */ + public function getComment(string $owner, string $repositoryName, string $commentId): string + { + $response = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/pulls/comments/' . \rawurlencode($commentId), + ['Authorization' => "Bearer {$this->accessToken}"] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + return ''; + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + + return \strval($responseBody['body'] ?? ''); + } + + /** + * Update Pull Request Comment + */ + public function updateComment(string $owner, string $repositoryName, string $commentId, string $comment): string + { + $response = $this->call( + self::METHOD_PATCH, + $this->repositoryPath($owner, $repositoryName) . '/pulls/comments/' . \rawurlencode($commentId), + ['Authorization' => "Bearer {$this->accessToken}"], + ['body' => $comment] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to update comment: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + if (!\array_key_exists('id', $responseBody)) { + throw new Exception('Comment update response is missing comment ID.'); + } + + return \strval($responseBody['id']); + } + + /** + * Generates a clone command using the installation access token. + * + * Origin's Git endpoint takes HTTP Basic credentials with the fixed + * username x-access-token and the installation token as the password. + */ + public function generateCloneCommand(string $owner, string $repositoryName, string $version, string $versionType, string $directory, string $rootDirectory): string + { + if (empty($rootDirectory)) { + $rootDirectory = '*'; + } + + $cloneUrl = $this->authenticatedCloneUrl($owner, $repositoryName); + + $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}", + // Enable sparse checkout + "git config core.sparseCheckout true", + "echo {$rootDirectory} >> .git/info/sparse-checkout", + // Disable fetching of refs we don't need + "git config --add remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'", + // Disable fetching of tags + "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/$(git ls-remote --tags origin {$tagName} | tail -n 1 | awk -F '/' '{print $3}') && git checkout FETCH_HEAD"; + break; + } + + return \implode(' && ', $commands); + } + + /** + * Git HTTPS URL carrying the installation token as Basic credentials. + */ + protected function authenticatedCloneUrl(string $owner, string $repositoryName): string + { + $credentials = !empty($this->accessToken) ? 'x-access-token:' . \urlencode($this->accessToken) . '@' : ''; + $host = \substr($this->gitEndpoint, \strlen('https://')); + + return 'https://' . $credentials . $host . '/' . \urlencode($owner) . '/' . \urlencode($repositoryName) . '.git'; + } + + /** + * Validates a webhook delivery signature. + * + * Origin signs deliveries with its own Ed25519 key instead of a shared + * HMAC secret. The signed content is + * "..", which the caller + * must assemble from the delivery headers and pass as $payload. The + * signature is verified over the lowercase hex SHA-256 digest of it. + * + * @param string $payload ".." + * @param string $signature The webhook-signature header, e.g. "v1ed,BASE64" + * @param string $signatureKey Origin's Ed25519 public key: SPKI PEM, or the + * base64/base64url raw 32 bytes (the JWK 'x' value) + */ + public function validateWebhookEvent(string $payload, string $signature, string $signatureKey): bool + { + $publicKey = $this->ed25519PublicKey($signatureKey); + if ($publicKey === null) { + return false; + } + + $digest = \hash('sha256', $payload); + + // The header may carry several space-separated signatures during key rotation + foreach (\preg_split('/\s+/', \trim($signature)) ?: [] as $candidate) { + if (!\str_starts_with($candidate, 'v1ed,')) { + continue; + } + + $decoded = \base64_decode(\substr($candidate, 5), true); + if ($decoded === false || \strlen($decoded) !== SODIUM_CRYPTO_SIGN_BYTES) { + continue; + } + + if (\sodium_crypto_sign_verify_detached($decoded, $digest, $publicKey)) { + return true; + } + } + + return false; + } + + /** + * Resolves a configured verification key to raw Ed25519 public key bytes. + * + * @return non-empty-string|null + */ + protected function ed25519PublicKey(string $signatureKey): ?string + { + $signatureKey = \trim($signatureKey); + + if (\str_contains($signatureKey, '-----BEGIN')) { + $body = \preg_replace('/-----[A-Z ]+-----|\s+/', '', $signatureKey) ?? ''; + $der = \base64_decode($body, true); + if ($der === false) { + return null; + } + + // In SPKI the Ed25519 key is the 32 bytes after the BIT STRING wrapper (03 21 00) + $marker = "\x03\x21\x00"; + $offset = \strpos($der, $marker); + $publicKey = $offset !== false ? \substr($der, $offset + 3, 32) : ''; + + return \strlen($publicKey) === SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES ? $publicKey : null; + } + + $decoded = \base64_decode(\strtr($signatureKey, '-_', '+/'), true); + if ($decoded !== false && \strlen($decoded) === SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { + return $decoded; + } + + return null; + } + + /** + * Parses webhook delivery payload + * + * Origin wraps every event in a delivery envelope whose event.type is + * authoritative, and one push delivery may update several refs, so a + * single delivery can describe several events. + * + * @param string $event Value of the webhook-event-type header + * @param string $payload The raw delivery body + * @return array> Parsed payloads as json objects + */ + public function getEvents(string $event, string $payload): array + { + $decoded = \json_decode($payload, true); + + if ($decoded === null || !\is_array($decoded)) { + throw new Exception('Invalid payload.'); + } + + $envelope = $decoded['event'] ?? null; + if (\is_array($envelope)) { + $type = \strval($envelope['type'] ?? $event); + $eventPayload = \is_array($envelope['payload'] ?? null) ? $envelope['payload'] : []; + $installationId = \strval($decoded['installationId'] ?? ''); + } else { + $type = $event; + $eventPayload = $decoded; + $installationId = ''; + } + + if ($type === self::EVENT_PUSH) { + return $this->pushEvents($eventPayload, $installationId); + } + + if (\array_key_exists($type, self::PULL_REQUEST_ACTIONS)) { + return $this->pullRequestEvents($type, $eventPayload, $installationId); + } + + if (\str_starts_with($type, 'installation.')) { + return $this->installationEvents($type, $eventPayload, $installationId); + } + + return []; + } + + /** + * @param array $payload + * @return array> + */ + protected function pushEvents(array $payload, string $installationId): array + { + $repository = \is_array($payload['repository'] ?? null) ? $payload['repository'] : []; + $repositoryOwner = \is_array($repository['owner'] ?? null) ? $repository['owner'] : []; + + $repositoryId = \strval($repository['id'] ?? ''); + $repositoryName = \strval($repository['name'] ?? ''); + $owner = \strval($repositoryOwner['slug'] ?? ''); + $repositoryUrl = !empty($owner) && !empty($repositoryName) ? $this->getRepositoryUrl($owner, $repositoryName) : ''; + + $refUpdates = \is_array($payload['refUpdates'] ?? null) ? $payload['refUpdates'] : []; + + $events = []; + foreach ($refUpdates as $refUpdate) { + if (!\is_array($refUpdate)) { + continue; + } + + $branch = $this->shortBranchName(\strval($refUpdate['ref'] ?? '')); + $headCommit = \is_array($refUpdate['headCommit'] ?? null) ? $refUpdate['headCommit'] : []; + $headCommitAuthor = \is_array($headCommit['author'] ?? null) ? $headCommit['author'] : []; + $commitHash = \strval($refUpdate['after'] ?? ''); + + $events[] = [ + 'branchCreated' => (bool) ($refUpdate['created'] ?? false), + 'branchDeleted' => (bool) ($refUpdate['deleted'] ?? false), + 'branch' => $branch, + 'branchUrl' => !empty($repositoryUrl) && !empty($branch) ? $this->getBranchUrl($owner, $repositoryName, $branch) : '', + 'repositoryId' => $repositoryId, + 'repositoryName' => $repositoryName, + 'repositoryUrl' => $repositoryUrl, + 'installationId' => $installationId, + 'commitHash' => $commitHash, + 'owner' => $owner, + 'authorUrl' => '', + 'authorAvatarUrl' => '', + 'headCommitAuthorName' => \strval($headCommitAuthor['name'] ?? ''), + 'headCommitAuthorEmail' => \strval($headCommitAuthor['email'] ?? ''), + 'headCommitMessage' => \strval($headCommit['message'] ?? ''), + 'headCommitUrl' => !empty($repositoryUrl) && !empty($commitHash) ? $this->getCommitUrl($owner, $repositoryName, $commitHash) : '', + 'external' => false, + 'pullRequestNumber' => '', + 'action' => '', + // Origin push deliveries carry no per-commit file lists + 'affectedFiles' => [], + ]; + } + + return $events; + } + + /** + * @param array $payload + * @return array> + */ + protected function pullRequestEvents(string $type, array $payload, string $installationId): array + { + $pullRequest = \is_array($payload['pullRequest'] ?? null) ? $payload['pullRequest'] : []; + $repository = \is_array($payload['repository'] ?? null) ? $payload['repository'] : []; + $repositoryOwner = \is_array($repository['owner'] ?? null) ? $repository['owner'] : []; + $head = \is_array($pullRequest['head'] ?? null) ? $pullRequest['head'] : []; + + $repositoryId = \strval($repository['id'] ?? ''); + $repositoryName = \strval($repository['name'] ?? ''); + $owner = \strval($repositoryOwner['slug'] ?? ''); + $repositoryUrl = !empty($owner) && !empty($repositoryName) ? $this->getRepositoryUrl($owner, $repositoryName) : ''; + $branch = $this->shortBranchName(\strval($head['ref'] ?? '')); + $commitHash = \strval($head['sha'] ?? ''); + + return [[ + 'branch' => $branch, + 'branchUrl' => !empty($repositoryUrl) && !empty($branch) ? $this->getBranchUrl($owner, $repositoryName, $branch) : '', + 'repositoryId' => $repositoryId, + 'repositoryName' => $repositoryName, + 'repositoryUrl' => $repositoryUrl, + 'installationId' => $installationId, + 'commitHash' => $commitHash, + 'owner' => $owner, + 'authorUrl' => '', + 'authorAvatarUrl' => '', + 'headCommitUrl' => !empty($repositoryUrl) && !empty($commitHash) ? $this->getCommitUrl($owner, $repositoryName, $commitHash) : '', + // Origin pull requests always come from a branch of the same repository + 'external' => false, + 'pullRequestNumber' => (int) ($pullRequest['number'] ?? 0), + 'action' => self::PULL_REQUEST_ACTIONS[$type], + ]]; + } + + /** + * @param array $payload + * @return array> + */ + protected function installationEvents(string $type, array $payload, string $installationId): array + { + $installation = \is_array($payload['installation'] ?? null) ? $payload['installation'] : []; + $target = \is_array($installation['target'] ?? null) ? $installation['target'] : []; + + if ($installationId === '') { + $installationId = \strval($installation['id'] ?? ''); + } + + return [[ + 'action' => \substr($type, \strlen('installation.')), + 'installationId' => $installationId, + 'userName' => \strval($target['slug'] ?? ''), + ]]; + } + + public function getEventHeaderName(): string + { + return 'webhook-event-type'; + } + + public function getSignatureHeaderName(): string + { + return 'webhook-signature'; + } + + public function getSupportedWebhookScopes(): array + { + return [self::WEBHOOK_SCOPE_INSTALLATION]; + } + + /** + * Create a webhook on a repository + * + * Origin delivers webhooks per app installation to the URL registered in + * the app settings; repositories carry no hooks of their own. + */ + public function createWebhook(string $owner, string $repositoryName, string $url, string $secret, array $events = ['push', 'pull_request']): int|string + { + throw new Exception('createWebhook() is not supported by Origin. Configure the webhook URL in the Origin app settings.'); + } + + public function getRepositoryUrl(string $owner, string $repositoryName): string + { + return "{$this->webEndpoint}/{$owner}/{$repositoryName}"; + } + + public function getBranchUrl(string $owner, string $repositoryName, string $branch): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/tree/{$branch}"; + } + + public function getCommitUrl(string $owner, string $repositoryName, string $commitHash): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/commit/{$commitHash}"; + } + + public function getFileUrl(string $owner, string $repositoryName, string $reference): string + { + return $this->getRepositoryUrl($owner, $repositoryName) . "/blob/{$reference}"; + } + + /** + * Lists branches for a given repository + * + * @return array List of branch names as array + */ + public function listBranches(string $owner, string $repositoryName): array + { + $branches = []; + $pageToken = ''; + + do { + $params = ['pageSize' => 100]; + if ($pageToken !== '') { + $params['pageToken'] = $pageToken; + } + + $response = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/branches', + ['Authorization' => "Bearer {$this->accessToken}"], + $params + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + // A missing repository, or one with no commits yet, has no branches + return []; + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + foreach (\is_array($responseBody['branches'] ?? null) ? $responseBody['branches'] : [] as $branch) { + if (\is_array($branch) && !empty($branch['name'])) { + $branches[] = \strval($branch['name']); + } + } + + $pageToken = \strval($responseBody['nextPageToken'] ?? ''); + } while ($pageToken !== ''); + + return $branches; + } + + /** + * Lists tags for a given repository, optionally filtered by a glob pattern. + * + * @return array List of tag names as array + */ + public function listTags(string $owner, string $repositoryName, string $search = ''): array + { + $response = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/git/matching-refs', + ['Authorization' => "Bearer {$this->accessToken}"], + ['ref' => 'tags/'] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + $responseBody = $response['body'] ?? []; + + // 404 is a missing repository, 409 one with no commits - neither has tags + if ($statusCode < 200 || $statusCode >= 300 || !\is_array($responseBody)) { + return []; + } + + $tags = []; + foreach ($responseBody as $ref) { + if (\is_array($ref) && \is_string($ref['ref'] ?? null)) { + $tags[] = \str_replace('refs/tags/', '', $ref['ref']); + } + } + + return $this->matchGlob($tags, $search); + } + + /** + * Updates status check of each commit + * + * Origin models CI feedback as check runs only. + */ + public function updateCommitStatus(string $repositoryName, string $SHA, string $owner, string $state, string $description = '', string $target_url = '', string $context = ''): void + { + throw new Exception('updateCommitStatus() is not supported by Origin. Use createCheckRun() instead.'); + } + + /** + * Get commit statuses + * + * @return array + */ + public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array + { + throw new Exception('getCommitStatuses() is not supported by Origin. Use getCheckRun() instead.'); + } + + /** + * Creates a check run for a commit. + * + * Origin upserts check runs keyed on (repository, head SHA, suite key, + * run key), so every created run gets a unique run key to keep this + * method's create-a-new-run semantics. The suite key stays the run name, + * which is what Origin's required-check configuration matches on. + * Annotations, images and actions are not supported by Origin and are + * ignored. + * + * @param array $annotations + * @param array $images + * @param array $actions + * @return array + */ + public function createCheckRun( + string $owner, + string $repositoryName, + string $headSha, + string $name, + string $status = 'queued', + string $conclusion = '', + string $title = '', + string $summary = '', + string $text = '', + array $annotations = [], + array $images = [], + array $actions = [], + string $detailsUrl = '', + string $externalId = '', + string $startedAt = '', + string $completedAt = '', + ): array { + if ($status === 'completed' && empty($conclusion)) { + throw new Exception("conclusion is required when status is 'completed'"); + } + + // Conclusion requires status=completed; auto-set completed_at if not provided. + if (!empty($conclusion)) { + $status = 'completed'; + if (empty($completedAt)) { + $completedAt = \gmdate('Y-m-d\TH:i:s\Z'); + } + } + + $externalId = !empty($externalId) ? $externalId : \uniqid('attempt-'); + + $checkRun = \array_merge( + [ + 'key' => $name . '/' . $externalId, + 'name' => $name, + 'status' => !empty($status) ? $status : 'queued', + 'externalId' => $externalId, + 'externalUpdatedAt' => $this->rfc3339Now(), + ], + \array_filter([ + 'conclusion' => $conclusion, + 'startedAt' => $startedAt, + 'completedAt' => $completedAt, + 'detailsUrl' => $detailsUrl, + ], fn ($value) => !empty($value)) + ); + + // Output requires both title and summary. + if (!empty($title) && !empty($summary)) { + $checkRun['output'] = \array_filter(['title' => $title, 'summary' => $summary, 'text' => $text], fn ($value) => !empty($value)); + } + + $response = $this->call( + self::METHOD_POST, + $this->repositoryPath($owner, $repositoryName) . '/check-runs', + ['Authorization' => "Bearer {$this->accessToken}"], + [ + 'headSha' => $headSha, + 'checkSuite' => [ + 'key' => $name, + 'name' => $name, + 'externalId' => $externalId, + ], + 'checkRun' => $checkRun, + ] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to create check run: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + + return $this->normalizeCheckRun( + \is_array($responseBody['checkRun'] ?? null) ? $responseBody['checkRun'] : [], + $owner, + $repositoryName + ); + } + + /** + * Gets a check run by ID. + * + * @return array + */ + public function getCheckRun(string $owner, string $repositoryName, string $checkRunId): array + { + return $this->normalizeCheckRun($this->fetchCheckRun($owner, $repositoryName, $checkRunId), $owner, $repositoryName); + } + + /** + * @return array The raw check run as Origin reports it + */ + protected function fetchCheckRun(string $owner, string $repositoryName, string $checkRunId): array + { + $response = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/check-runs/' . \rawurlencode($checkRunId), + ['Authorization' => "Bearer {$this->accessToken}"] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get check run {$checkRunId}: HTTP {$statusCode}", (int) $statusCode); + } + + return \is_array($response['body'] ?? null) ? $response['body'] : []; + } + + /** + * Updates an existing check run. + * + * Origin has no update-by-id call, so the run and its suite are read back + * and re-upserted under the same keys and external id, which updates the + * attempt in place. + * + * @param array $annotations + * @param array $images + * @param array $actions + * @return array + */ + public function updateCheckRun( + string $owner, + string $repositoryName, + string $checkRunId, + string $name = '', + string $status = '', + string $conclusion = '', + string $title = '', + string $summary = '', + string $text = '', + array $annotations = [], + array $images = [], + array $actions = [], + string $detailsUrl = '', + string $externalId = '', + string $startedAt = '', + string $completedAt = '', + ): array { + if ($status === 'completed' && empty($conclusion)) { + throw new Exception("conclusion is required when status is 'completed'"); + } + + // Conclusion requires status=completed; auto-set completed_at if not provided. + if (!empty($conclusion)) { + $status = 'completed'; + if (empty($completedAt)) { + $completedAt = \gmdate('Y-m-d\TH:i:s\Z'); + } + } + + $existing = $this->fetchCheckRun($owner, $repositoryName, $checkRunId); + $existingSuite = \is_array($existing['checkSuite'] ?? null) ? $existing['checkSuite'] : []; + $suite = $this->fetchCheckSuite($owner, $repositoryName, \strval($existingSuite['id'] ?? '')); + + $existingOutput = \is_array($existing['output'] ?? null) ? $existing['output'] : []; + + $checkRun = \array_merge( + [ + 'key' => \strval($existing['key'] ?? ''), + 'name' => !empty($name) ? $name : \strval($existing['name'] ?? ''), + 'status' => !empty($status) ? $status : \strval($existing['status'] ?? ''), + // Reusing the external id updates this attempt instead of creating a retry + 'externalId' => \strval($existing['externalId'] ?? ''), + 'externalUpdatedAt' => $this->rfc3339Now(), + ], + \array_filter([ + 'conclusion' => !empty($conclusion) ? $conclusion : \strval($existing['conclusion'] ?? ''), + 'startedAt' => !empty($startedAt) ? $startedAt : \strval($existing['startedAt'] ?? ''), + 'completedAt' => !empty($completedAt) ? $completedAt : \strval($existing['completedAt'] ?? ''), + 'detailsUrl' => !empty($detailsUrl) ? $detailsUrl : \strval($existing['detailsUrl'] ?? ''), + ], fn ($value) => !empty($value)) + ); + + // Output requires both title and summary; keep what the run already reports otherwise. + if (!empty($title) && !empty($summary)) { + $checkRun['output'] = \array_filter(['title' => $title, 'summary' => $summary, 'text' => $text], fn ($value) => !empty($value)); + } elseif (!empty($existingOutput)) { + $checkRun['output'] = $existingOutput; + } + + $response = $this->call( + self::METHOD_POST, + $this->repositoryPath($owner, $repositoryName) . '/check-runs', + ['Authorization' => "Bearer {$this->accessToken}"], + [ + 'headSha' => \strval($existing['sha'] ?? ''), + 'checkSuite' => \array_merge( + [ + 'key' => \strval($suite['key'] ?? ''), + 'name' => \strval($suite['name'] ?? ''), + 'externalId' => \strval($suite['externalId'] ?? ''), + ], + \array_filter(['detailsUrl' => \strval($suite['detailsUrl'] ?? '')], fn ($value) => !empty($value)) + ), + 'checkRun' => $checkRun, + ] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to update check run {$checkRunId}: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + + return $this->normalizeCheckRun( + \is_array($responseBody['checkRun'] ?? null) ? $responseBody['checkRun'] : [], + $owner, + $repositoryName + ); + } + + /** + * @return array + */ + protected function fetchCheckSuite(string $owner, string $repositoryName, string $checkSuiteId): array + { + $response = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/check-suites/' . \rawurlencode($checkSuiteId), + ['Authorization' => "Bearer {$this->accessToken}"] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get check suite {$checkSuiteId}: HTTP {$statusCode}", (int) $statusCode); + } + + return \is_array($response['body'] ?? null) ? $response['body'] : []; + } + + /** + * Reshapes an Origin check run to the GitHub-style contract shape. + * + * @param array $checkRun + * @return array + */ + protected function normalizeCheckRun(array $checkRun, string $owner, string $repositoryName): array + { + $sha = \strval($checkRun['sha'] ?? ''); + $id = \strval($checkRun['id'] ?? ''); + $output = \is_array($checkRun['output'] ?? null) ? $checkRun['output'] : []; + $checkSuite = \is_array($checkRun['checkSuite'] ?? null) ? $checkRun['checkSuite'] : []; + + return [ + 'id' => $id, + 'name' => \strval($checkRun['name'] ?? ''), + 'status' => \strval($checkRun['status'] ?? ''), + 'conclusion' => !empty($checkRun['conclusion']) ? \strval($checkRun['conclusion']) : null, + 'head_sha' => $sha, + 'url' => $this->endpoint . $this->repositoryPath($owner, $repositoryName) . '/check-runs/' . $id, + 'html_url' => !empty($sha) ? $this->getCommitUrl($owner, $repositoryName, $sha) : $this->getRepositoryUrl($owner, $repositoryName), + 'started_at' => !empty($checkRun['startedAt']) ? \strval($checkRun['startedAt']) : null, + 'completed_at' => !empty($checkRun['completedAt']) ? \strval($checkRun['completedAt']) : null, + 'external_id' => \strval($checkRun['externalId'] ?? ''), + 'key' => \strval($checkRun['key'] ?? ''), + 'check_suite_id' => \strval($checkSuite['id'] ?? ''), + 'output' => [ + 'title' => \strval($output['title'] ?? ''), + 'summary' => \strval($output['summary'] ?? ''), + 'text' => \strval($output['text'] ?? ''), + ], + ]; + } + + /** + * RFC 3339 timestamp with microseconds. Origin orders check run upserts by + * externalUpdatedAt, so consecutive updates need sub-second resolution. + */ + protected function rfc3339Now(): string + { + $now = \DateTime::createFromFormat('U.u', \sprintf('%.6F', \microtime(true))); + + return $now !== false ? $now->format('Y-m-d\TH:i:s.u\Z') : \gmdate('Y-m-d\TH:i:s\Z'); + } + + /** + * Get repository tree + * + * @return array List of files in the repository + */ + public function getRepositoryTree(string $owner, string $repositoryName, string $branch, bool $recursive = false): array + { + // Branch names may contain slashes, which would be read as extra path + // segments, so resolve the branch to its tip SHA first + $sha = $branch; + if (!\preg_match('/^[0-9a-f]{40}([0-9a-f]{24})?$/i', $branch)) { + $refResponse = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/git/ref/heads/' . $this->encodeRef($branch), + ['Authorization' => "Bearer {$this->accessToken}"] + ); + + $refStatusCode = $refResponse['headers']['status-code'] ?? 0; + if ($refStatusCode >= 400) { + return []; + } + + $refBody = \is_array($refResponse['body'] ?? null) ? $refResponse['body'] : []; + $refObject = \is_array($refBody['object'] ?? null) ? $refBody['object'] : []; + $sha = \strval($refObject['sha'] ?? ''); + if ($sha === '') { + return []; + } + } + + // Any non-empty recursive value enables recursion, so send it only when wanted + $response = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/git/trees/' . $sha . ($recursive ? '?recursive=1' : ''), + ['Authorization' => "Bearer {$this->accessToken}"] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + return []; + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + + return \array_column(\is_array($responseBody['tree'] ?? null) ? $responseBody['tree'] : [], 'path'); + } + + /** + * Get repository languages + * + * Origin computes no language statistics, so the answer is always empty + * rather than an error, letting consumers render an absent breakdown. + * + * @return array + */ + public function listRepositoryLanguages(string $owner, string $repositoryName): array + { + return []; + } + + /** + * List contents of the specified root directory. + * + * @return array List of contents at the specified path + */ + public function listRepositoryContents(string $owner, string $repositoryName, string $path = '', string $ref = ''): array + { + $params = ['path' => $this->normalizeRepositoryPath($path)]; + if (!empty($ref)) { + $params['ref'] = $ref; + } + + $response = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/contents', + ['Authorization' => "Bearer {$this->accessToken}"], + $params + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + return []; + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + + $items = []; + if (($responseBody['type'] ?? '') === 'dir') { + $items = \is_array($responseBody['entries'] ?? null) ? $responseBody['entries'] : []; + } elseif (!empty($responseBody)) { + $items = [$responseBody]; + } + + $contents = []; + foreach ($items as $item) { + if (!\is_array($item)) { + continue; + } + + $contents[] = [ + 'name' => \strval($item['name'] ?? ''), + 'size' => (int) ($item['size'] ?? 0), + 'type' => ($item['type'] ?? 'file') === 'dir' ? self::CONTENTS_DIRECTORY : self::CONTENTS_FILE, + ]; + } + + return $contents; + } + + /** + * Get contents of the specified file. + * + * @return array File details + */ + public function getRepositoryContent(string $owner, string $repositoryName, string $path, string $ref = ''): array + { + $params = ['path' => $this->normalizeRepositoryPath($path)]; + if (!empty($ref)) { + $params['ref'] = $ref; + } + + $response = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/contents', + ['Authorization' => "Bearer {$this->accessToken}"], + $params + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode !== 200) { + throw new FileNotFound(); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + + if (($responseBody['type'] ?? '') !== 'file' || ($responseBody['encoding'] ?? '') !== 'base64') { + throw new FileNotFound(); + } + + return [ + 'sha' => \strval($responseBody['sha'] ?? ''), + // Sizes ride as JSON strings under the API's 64-bit integer convention + 'size' => (int) ($responseBody['size'] ?? 0), + 'content' => \base64_decode(\strval($responseBody['content'] ?? '')), + ]; + } + + /** + * Get details of a commit using commit hash + * + * @return array Details of the commit + */ + public function getCommit(string $owner, string $repositoryName, string $commitHash): array + { + $response = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/commits/' . $this->encodeRef($commitHash), + ['Authorization' => "Bearer {$this->accessToken}"] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode === 404) { + throw new RepositoryNotFound('Commit not found.'); + } + if ($statusCode >= 400) { + throw new Exception('Commit not found or inaccessible.', (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + $commit = \is_array($responseBody['commit'] ?? null) ? $responseBody['commit'] : []; + $author = \is_array($commit['author'] ?? null) ? $commit['author'] : []; + $sha = \strval($responseBody['sha'] ?? ''); + + return [ + 'commitAuthor' => \strval($author['name'] ?? 'Unknown'), + 'commitMessage' => \strval($commit['message'] ?? 'No message'), + // Origin reports git identities, not linked accounts + 'commitAuthorAvatar' => '', + 'commitAuthorUrl' => '', + 'commitHash' => $sha, + 'commitUrl' => !empty($sha) ? $this->getCommitUrl($owner, $repositoryName, $sha) : '', + ]; + } + + /** + * Get latest commit of a branch + * + * @return array Details of the commit + */ + public function getLatestCommit(string $owner, string $repositoryName, string $branch): array + { + try { + return $this->getCommit($owner, $repositoryName, $branch); + } catch (RepositoryNotFound) { + throw new RepositoryNotFound("Branch not found: {$branch}"); + } + } + + /** + * Create a file in a repository + * + * Origin has no contents-write API - content changes travel over Git + * HTTPS - so this commits the file locally and pushes it, which requires + * the `git` binary. + * + * @return array + */ + public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file', string $branch = ''): array + { + // Also resolves the default branch and confirms the repository exists + $repository = $this->getRepository($owner, $repositoryName); + $defaultBranch = \strval($repository['defaultBranch'] ?? 'main'); + $targetBranch = !empty($branch) ? $branch : $defaultBranch; + + $remote = \escapeshellarg($this->authenticatedCloneUrl($owner, $repositoryName)); + $directory = $this->temporaryDirectory(); + $git = 'git -C ' . \escapeshellarg($directory); + + try { + $this->execute("{$git} init -q", 'Initializing the working repository'); + + // Base the commit on the branch tip when there is one; a missing + // target branch grows from the default branch, and an empty + // repository starts from scratch + $hasBase = $this->tryExecute("{$git} fetch -q --depth=1 {$remote} " . \escapeshellarg('refs/heads/' . $targetBranch)) + || (!empty($branch) && $branch !== $defaultBranch && $this->tryExecute("{$git} fetch -q --depth=1 {$remote} " . \escapeshellarg('refs/heads/' . $defaultBranch))); + if ($hasBase) { + $this->execute("{$git} checkout -q FETCH_HEAD", 'Checking out the branch tip'); + } + + $absolute = $directory . '/' . \ltrim($filepath, '/'); + $parent = \dirname($absolute); + if (!\is_dir($parent) && !\mkdir($parent, 0777, true)) { + throw new Exception("Failed to create directory for {$filepath}"); + } + if (\file_put_contents($absolute, $content) === false) { + throw new Exception("Failed to write {$filepath}"); + } + + $this->execute("{$git} add " . \escapeshellarg($filepath), 'Staging the file'); + $this->execute("{$git} -c user.name='Utopia VCS' -c user.email='vcs@utopia.dev' commit -q -m " . \escapeshellarg($message), 'Committing the file'); + $commitHash = \implode('', $this->execute("{$git} rev-parse HEAD", 'Reading the commit hash')); + $this->execute("{$git} push -q {$remote} " . \escapeshellarg('HEAD:refs/heads/' . $targetBranch), 'Pushing the commit'); + + return [ + 'path' => $filepath, + 'branch' => $targetBranch, + 'commitHash' => $commitHash, + ]; + } finally { + $this->removeDirectory($directory); + } + } + + /** + * Create a branch in a repository + * + * Pushed over Git HTTPS; the remote already holds the objects, so only a + * ref update travels. + * + * @return array + */ + public function createBranch(string $owner, string $repositoryName, string $newBranchName, string $oldBranchName): array + { + $remote = \escapeshellarg($this->authenticatedCloneUrl($owner, $repositoryName)); + $directory = $this->temporaryDirectory(); + $git = 'git -C ' . \escapeshellarg($directory); + + try { + $this->execute("{$git} init -q", 'Initializing the working repository'); + $this->execute("{$git} fetch -q --depth=1 {$remote} " . \escapeshellarg('refs/heads/' . $oldBranchName), "Fetching branch {$oldBranchName}"); + $sha = \implode('', $this->execute("{$git} rev-parse FETCH_HEAD", 'Reading the branch tip')); + $this->execute("{$git} push -q {$remote} " . \escapeshellarg('FETCH_HEAD:refs/heads/' . $newBranchName), "Creating branch {$newBranchName}"); + + return [ + 'name' => $newBranchName, + 'ref' => 'refs/heads/' . $newBranchName, + 'sha' => $sha, + ]; + } finally { + $this->removeDirectory($directory); + } + } + + /** + * Create a tag in a repository + * + * Pushed over Git HTTPS. A message produces an annotated tag, no message + * a lightweight one. + * + * @return array + */ + public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array + { + $remote = \escapeshellarg($this->authenticatedCloneUrl($owner, $repositoryName)); + $directory = $this->temporaryDirectory(); + $git = 'git -C ' . \escapeshellarg($directory); + + try { + $this->execute("{$git} init -q", 'Initializing the working repository'); + + // Prefer fetching the target object directly; fall back to every + // branch head for servers that refuse fetch-by-SHA + if (!$this->tryExecute("{$git} fetch -q --depth=1 {$remote} " . \escapeshellarg($target))) { + $this->execute("{$git} fetch -q {$remote} " . \escapeshellarg('refs/heads/*:refs/remotes/origin/*'), "Fetching commit {$target}"); + } + + if (!empty($message)) { + $this->execute( + "{$git} -c user.name='Utopia VCS' -c user.email='vcs@utopia.dev' tag -a " . \escapeshellarg($tagName) . ' -m ' . \escapeshellarg($message) . ' ' . \escapeshellarg($target), + "Creating tag {$tagName}" + ); + $this->execute("{$git} push -q {$remote} " . \escapeshellarg('refs/tags/' . $tagName), "Pushing tag {$tagName}"); + } else { + $this->execute("{$git} push -q {$remote} " . \escapeshellarg($target . ':refs/tags/' . $tagName), "Pushing tag {$tagName}"); + } + + return [ + 'name' => $tagName, + 'sha' => $target, + 'message' => $message, + ]; + } finally { + $this->removeDirectory($directory); + } + } + + protected function temporaryDirectory(): string + { + $directory = \sys_get_temp_dir() . '/utopia-vcs-origin-' . \uniqid(); + if (!\mkdir($directory, 0777, true)) { + throw new Exception('Failed to create a temporary directory for the Git operation.'); + } + + return $directory; + } + + protected function removeDirectory(string $directory): void + { + if (\is_dir($directory)) { + \exec('rm -rf ' . \escapeshellarg($directory)); + } + } + + /** + * Runs a shell command and throws on failure, with the access token + * scrubbed out of whatever the command printed. + * + * @return array Output lines + */ + protected function execute(string $command, string $action): array + { + $output = []; + $exitCode = 0; + \exec($command . ' 2>&1', $output, $exitCode); + + if ($exitCode !== 0) { + $printed = \implode("\n", $output); + if (!empty($this->accessToken)) { + $printed = \str_replace([$this->accessToken, \urlencode($this->accessToken)], '***', $printed); + } + + throw new Exception("{$action} failed: {$printed}"); + } + + return $output; + } + + protected function tryExecute(string $command): bool + { + $output = []; + $exitCode = 0; + \exec($command . ' 2>&1', $output, $exitCode); + + return $exitCode === 0; + } + + /** + * Update a pull request's title, body, base branch and/or lifecycle state. + * + * Omitted (null) fields are left unchanged. $state is 'open' or 'closed'; + * merged is not writable, use mergePullRequest(). $draft true marks the + * pull request a draft, false publishes it (and reopens a closed one). + * + * @return array The updated pull request + */ + public function updatePullRequest(string $owner, string $repositoryName, int $pullRequestNumber, ?string $title = null, ?string $body = null, ?string $state = null, ?bool $draft = null, ?string $base = null): array + { + $params = []; + if ($title !== null) { + $params['title'] = $title; + } + if ($body !== null) { + $params['body'] = $body; + } + if ($state !== null) { + $params['state'] = $state; + } + if ($draft !== null) { + $params['draft'] = $draft; + } + if ($base !== null) { + $params['base'] = $base; + } + + $response = $this->call( + self::METHOD_PATCH, + $this->repositoryPath($owner, $repositoryName) . '/pulls/' . $pullRequestNumber, + ['Authorization' => "Bearer {$this->accessToken}"], + $params + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to update pull request: HTTP {$statusCode}", (int) $statusCode); + } + + return $this->normalizePullRequest(\is_array($response['body'] ?? null) ? $response['body'] : []); + } + + /** + * Merge a pull request into its base. For a stacked pull request this + * merges everything from the stack root through the given number. + * Mirrored repositories are rejected by Origin. + * + * @return array mergeCommitSha, mergedPullNumbers and the merged pull request + */ + public function mergePullRequest(string $owner, string $repositoryName, int $pullRequestNumber): array + { + $response = $this->call( + self::METHOD_POST, + $this->repositoryPath($owner, $repositoryName) . '/pulls/' . $pullRequestNumber . '/merge', + ['Authorization' => "Bearer {$this->accessToken}"] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to merge pull request: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + + return [ + 'mergeCommitSha' => \strval($responseBody['mergeCommitSha'] ?? ''), + 'mergedPullNumbers' => \array_map('intval', \is_array($responseBody['mergedPullNumbers'] ?? null) ? $responseBody['mergedPullNumbers'] : []), + 'pullRequest' => $this->normalizePullRequest(\is_array($responseBody['pullRequest'] ?? null) ? $responseBody['pullRequest'] : []), + ]; + } + + /** + * List pull requests, optionally filtered by head branch. + * + * @param string $state 'open', 'closed' or 'all' + * @return array + */ + public function listPullRequests(string $owner, string $repositoryName, string $state = 'open', string $head = ''): array + { + $params = ['state' => $state]; + if ($head !== '') { + $params['head'] = $head; + } + + $pullRequests = $this->collectPages( + $this->repositoryPath($owner, $repositoryName) . '/pulls', + 'pullRequests', + $params + ); + + return \array_map( + fn ($pullRequest) => $this->normalizePullRequest(\is_array($pullRequest) ? $pullRequest : []), + $pullRequests + ); + } + + /** + * List general-discussion comments on a pull request. + * + * @return array + */ + public function listPullRequestComments(string $owner, string $repositoryName, int $pullRequestNumber): array + { + return $this->collectPages( + $this->repositoryPath($owner, $repositoryName) . '/pulls/' . $pullRequestNumber . '/comments', + 'comments' + ); + } + + /** + * List the commits a pull request carries. + * + * @return array + */ + public function listPullRequestCommits(string $owner, string $repositoryName, int $pullRequestNumber): array + { + return $this->collectPages( + $this->repositoryPath($owner, $repositoryName) . '/pulls/' . $pullRequestNumber . '/commits', + 'commits' + ); + } + + /** + * Create and submit a review on a pull request. + * + * $verdict is 'approve', 'request_changes' or 'comment'. Origin refuses + * 'approve' from the pull request's own author. + * + * @param string $versionNumber Pull request version to review; empty reviews the latest + * @return array The submitted review + */ + public function createPullRequestReview(string $owner, string $repositoryName, int $pullRequestNumber, string $verdict, string $body = '', string $versionNumber = ''): array + { + $params = ['verdict' => $verdict, 'body' => $body]; + if ($versionNumber !== '') { + $params['versionNumber'] = $versionNumber; + } + + $response = $this->call( + self::METHOD_POST, + $this->repositoryPath($owner, $repositoryName) . '/pulls/' . $pullRequestNumber . '/reviews', + ['Authorization' => "Bearer {$this->accessToken}"], + $params + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to create pull request review: HTTP {$statusCode}", (int) $statusCode); + } + + return \is_array($response['body'] ?? null) ? $response['body'] : []; + } + + /** + * List submitted reviews on a pull request. Dismissed reviews stay listed + * with their `dismissal` field set. + * + * @return array + */ + public function listPullRequestReviews(string $owner, string $repositoryName, int $pullRequestNumber): array + { + return $this->collectPages( + $this->repositoryPath($owner, $repositoryName) . '/pulls/' . $pullRequestNumber . '/reviews', + 'reviews' + ); + } + + /** + * Replace the body of a review. Only the review's author may update it. + * + * @return array The updated review + */ + public function updatePullRequestReview(string $owner, string $repositoryName, int $pullRequestNumber, string $reviewId, string $body): array + { + $response = $this->call( + self::METHOD_PATCH, + $this->repositoryPath($owner, $repositoryName) . '/pulls/' . $pullRequestNumber . '/reviews/' . \rawurlencode($reviewId), + ['Authorization' => "Bearer {$this->accessToken}"], + ['body' => $body] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to update pull request review: HTTP {$statusCode}", (int) $statusCode); + } + + return \is_array($response['body'] ?? null) ? $response['body'] : []; + } + + /** + * Dismiss a submitted approve/request_changes review so its verdict no + * longer counts. The review stays listed with `dismissal` set. + * + * @return array The dismissed review + */ + public function dismissPullRequestReview(string $owner, string $repositoryName, int $pullRequestNumber, string $reviewId, string $message): array + { + $response = $this->call( + self::METHOD_PUT, + $this->repositoryPath($owner, $repositoryName) . '/pulls/' . $pullRequestNumber . '/reviews/' . \rawurlencode($reviewId) . '/dismissals', + ['Authorization' => "Bearer {$this->accessToken}"], + ['message' => $message] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to dismiss pull request review: HTTP {$statusCode}", (int) $statusCode); + } + + return \is_array($response['body'] ?? null) ? $response['body'] : []; + } + + /** + * List commits reachable from a SHA, branch, tag or symbolic ref; an + * empty $sha lists from the default branch. Reported in the same shape + * as getCommit(), without stats. + * + * @return array + */ + public function listCommits(string $owner, string $repositoryName, string $sha = ''): array + { + $params = []; + if ($sha !== '') { + $params['sha'] = $sha; + } + + $commits = $this->collectPages( + $this->repositoryPath($owner, $repositoryName) . '/commits', + 'commits', + $params + ); + + $normalized = []; + foreach ($commits as $item) { + if (!\is_array($item)) { + continue; + } + + $commit = \is_array($item['commit'] ?? null) ? $item['commit'] : []; + $author = \is_array($commit['author'] ?? null) ? $commit['author'] : []; + $commitSha = \strval($item['sha'] ?? ''); + + $normalized[] = [ + 'commitAuthor' => \strval($author['name'] ?? ''), + 'commitMessage' => \strval($commit['message'] ?? ''), + 'commitAuthorAvatar' => '', + 'commitAuthorUrl' => '', + 'commitHash' => $commitSha, + 'commitUrl' => !empty($commitSha) ? $this->getCommitUrl($owner, $repositoryName, $commitSha) : '', + ]; + } + + return $normalized; + } + + /** + * List the files a commit changed, with unified patches. + * + * @return array + */ + public function listCommitFiles(string $owner, string $repositoryName, string $sha): array + { + return $this->collectPages( + $this->repositoryPath($owner, $repositoryName) . '/commits/' . $this->encodeRef($sha) . '/files', + 'files' + ); + } + + /** + * Compare two commits, refs or tags relative to their merge base. + * + * The summary reports status (identical, ahead, behind or diverged), + * aheadBy/behindBy counts and the three resolved commits; Origin embeds + * no commit lists or file diffs. Refs containing '/' must be passed as + * their SHA - the separator would be read as part of the path. + * + * @return array + */ + public function compareCommits(string $owner, string $repositoryName, string $base, string $head): array + { + $response = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/compare/' . \rawurlencode($base) . '...' . \rawurlencode($head), + ['Authorization' => "Bearer {$this->accessToken}"] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to compare commits: HTTP {$statusCode}", (int) $statusCode); + } + + return \is_array($response['body'] ?? null) ? $response['body'] : []; + } + + /** + * Get a Git blob by SHA, decoded. Blobs over 4 MiB are rejected by Origin. + * + * @return array sha, size and decoded content + */ + public function getBlob(string $owner, string $repositoryName, string $sha): array + { + $response = $this->call( + self::METHOD_GET, + $this->repositoryPath($owner, $repositoryName) . '/git/blobs/' . \rawurlencode($sha), + ['Authorization' => "Bearer {$this->accessToken}"] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new FileNotFound(); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + + // MIME-wrapped base64: base64_decode() skips the line breaks + return [ + 'sha' => \strval($responseBody['sha'] ?? ''), + 'size' => (int) ($responseBody['size'] ?? 0), + 'content' => \base64_decode(\strval($responseBody['content'] ?? '')), + ]; + } + + /** + * Get the contents of up to 20 explicit paths at one ref in a single + * request. Exact matches only, no globs. + * + * @param array $paths + * @return array Decoded content per requested path, null where the path was not found or is not a file + */ + public function batchGetRepositoryContents(string $owner, string $repositoryName, array $paths, string $ref = ''): array + { + $params = ['paths' => \array_values($paths)]; + if ($ref !== '') { + $params['ref'] = $ref; + } + + $response = $this->call( + self::METHOD_POST, + $this->repositoryPath($owner, $repositoryName) . '/contents:batchGet', + ['Authorization' => "Bearer {$this->accessToken}"], + $params + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to batch get contents: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + + $contents = []; + foreach (\is_array($responseBody['results'] ?? null) ? $responseBody['results'] : [] as $result) { + if (!\is_array($result)) { + continue; + } + + $path = \strval($result['path'] ?? ''); + $content = \is_array($result['content'] ?? null) ? $result['content'] : []; + + $contents[$path] = !empty($result['found']) && ($content['type'] ?? '') === 'file' + ? \base64_decode(\strval($content['content'] ?? '')) + : null; + } + + return $contents; + } + + /** + * Get a check suite by ID, as Origin reports it. + * + * @return array + */ + public function getCheckSuite(string $owner, string $repositoryName, string $checkSuiteId): array + { + return $this->fetchCheckSuite($owner, $repositoryName, $checkSuiteId); + } + + /** + * List every check run reported against a commit, across suites. + * + * @return array Check runs in the contract shape of getCheckRun() + */ + public function listCheckRunsForCommit(string $owner, string $repositoryName, string $sha): array + { + $checkRuns = $this->collectPages( + $this->repositoryPath($owner, $repositoryName) . '/commits/' . $this->encodeRef($sha) . '/check-runs', + 'checkRuns' + ); + + return \array_map( + fn ($checkRun) => $this->normalizeCheckRun(\is_array($checkRun) ? $checkRun : [], $owner, $repositoryName), + $checkRuns + ); + } + + /** + * List the check runs belonging to one check suite. + * + * @return array Check runs in the contract shape of getCheckRun() + */ + public function listCheckRunsForSuite(string $owner, string $repositoryName, string $checkSuiteId): array + { + $checkRuns = $this->collectPages( + $this->repositoryPath($owner, $repositoryName) . '/check-suites/' . \rawurlencode($checkSuiteId) . '/check-runs', + 'checkRuns' + ); + + return \array_map( + fn ($checkRun) => $this->normalizeCheckRun(\is_array($checkRun) ? $checkRun : [], $owner, $repositoryName), + $checkRuns + ); + } + + /** + * Atomically create or update up to 10 check runs in one suite. All runs + * are committed or the whole request rolls back. + * + * Each entry needs a 'name' plus optionally 'status', 'conclusion', + * 'title', 'summary', 'text', 'detailsUrl', 'externalId', 'startedAt', + * 'completedAt' and 'key', with the same defaults as createCheckRun(). + * + * @param array> $checkRuns + * @return array Persisted check runs in request order, in the contract shape of getCheckRun() + */ + public function batchUpsertCheckRuns(string $owner, string $repositoryName, string $headSha, string $suiteName, array $checkRuns): array + { + $suiteExternalId = \uniqid('attempt-'); + + $runs = []; + foreach ($checkRuns as $input) { + $name = \strval($input['name'] ?? ''); + $status = \strval($input['status'] ?? 'queued'); + $conclusion = \strval($input['conclusion'] ?? ''); + $completedAt = \strval($input['completedAt'] ?? ''); + + if ($status === 'completed' && empty($conclusion)) { + throw new Exception("conclusion is required when status is 'completed'"); + } + + if (!empty($conclusion)) { + $status = 'completed'; + if (empty($completedAt)) { + $completedAt = \gmdate('Y-m-d\TH:i:s\Z'); + } + } + + $externalId = \strval($input['externalId'] ?? '') ?: \uniqid('attempt-'); + + $run = \array_merge( + [ + 'key' => \strval($input['key'] ?? '') ?: $name . '/' . $externalId, + 'name' => $name, + 'status' => $status, + 'externalId' => $externalId, + 'externalUpdatedAt' => $this->rfc3339Now(), + ], + \array_filter([ + 'conclusion' => $conclusion, + 'startedAt' => \strval($input['startedAt'] ?? ''), + 'completedAt' => $completedAt, + 'detailsUrl' => \strval($input['detailsUrl'] ?? ''), + ], fn ($value) => !empty($value)) + ); + + $title = \strval($input['title'] ?? ''); + $summary = \strval($input['summary'] ?? ''); + if (!empty($title) && !empty($summary)) { + $run['output'] = \array_filter([ + 'title' => $title, + 'summary' => $summary, + 'text' => \strval($input['text'] ?? ''), + ], fn ($value) => !empty($value)); + } + + $runs[] = $run; + } + + $response = $this->call( + self::METHOD_POST, + $this->repositoryPath($owner, $repositoryName) . '/check-runs:batchUpsert', + ['Authorization' => "Bearer {$this->accessToken}"], + [ + 'headSha' => $headSha, + 'checkSuite' => [ + 'key' => $suiteName, + 'name' => $suiteName, + 'externalId' => $suiteExternalId, + ], + 'checkRuns' => $runs, + ] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to batch upsert check runs: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + + return \array_map( + fn ($checkRun) => $this->normalizeCheckRun(\is_array($checkRun) ? $checkRun : [], $owner, $repositoryName), + \is_array($responseBody['checkRuns'] ?? null) ? $responseBody['checkRuns'] : [] + ); + } + + /** + * Metadata of the authenticated app: slug, display name, webhook URL and + * event subscriptions. Authenticates with the app JWT. + * + * @return array + */ + public function getAuthenticatedApp(): array + { + $response = $this->call(self::METHOD_GET, '/app', ['Authorization' => "Bearer {$this->jwtToken}"]); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get the authenticated app: HTTP {$statusCode}", (int) $statusCode); + } + + return \is_array($response['body'] ?? null) ? $response['body'] : []; + } + + /** + * List every installation of the authenticated app. Authenticates with + * the app JWT. + * + * @return array + */ + public function listInstallations(): array + { + return $this->collectPages('/app/installations', 'installations', [], "Bearer {$this->jwtToken}"); + } + + /** + * Delete an installation, preventing new tokens from being minted for it. + * Already-issued tokens stay valid until they expire (at most 15 minutes). + */ + public function deleteInstallation(string $installationId): bool + { + $response = $this->call( + self::METHOD_DELETE, + '/app/installations/' . \rawurlencode($installationId), + ['Authorization' => "Bearer {$this->jwtToken}"] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to delete installation {$installationId}: HTTP {$statusCode}", (int) $statusCode); + } + + return true; + } + + /** + * One page of the app's webhook deliveries, newest first. Authenticates + * with the app JWT. Pass $delivered=false as the recovery predicate: every + * delivery never acknowledged with a 2xx. + * + * @return array{deliveries: array, nextPageToken: string} + */ + public function listWebhookDeliveries(?bool $delivered = null, string $eventType = '', string $installationId = '', string $pageToken = ''): array + { + $params = []; + if ($delivered !== null) { + $params['delivered'] = $delivered ? 'true' : 'false'; + } + if ($eventType !== '') { + $params['eventType'] = $eventType; + } + if ($installationId !== '') { + $params['installationId'] = $installationId; + } + if ($pageToken !== '') { + $params['pageToken'] = $pageToken; + } + + $response = $this->call( + self::METHOD_GET, + '/app/webhook/deliveries', + ['Authorization' => "Bearer {$this->jwtToken}"], + $params + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to list webhook deliveries: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + + return [ + 'deliveries' => \is_array($responseBody['deliveries'] ?? null) ? $responseBody['deliveries'] : [], + 'nextPageToken' => \strval($responseBody['nextPageToken'] ?? ''), + ]; + } + + /** + * Ask Origin to send up to 100 deliveries again. Authenticates with the + * app JWT. Duplicates are removed and a bad ID cannot block the rest. + * + * @param array $deliveryIds + * @return array Outcome per delivery ID: queued, already_in_flight or not_found + */ + public function redeliverWebhookDeliveries(array $deliveryIds): array + { + $response = $this->call( + self::METHOD_POST, + '/app/webhook/deliveries:batchRedeliver', + ['Authorization' => "Bearer {$this->jwtToken}"], + ['deliveryIds' => \array_values($deliveryIds)] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to redeliver webhook deliveries: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + + $outcomes = []; + foreach (\is_array($responseBody['results'] ?? null) ? $responseBody['results'] : [] as $result) { + if (\is_array($result)) { + $outcomes[\strval($result['deliveryId'] ?? '')] = \strval($result['outcome'] ?? ''); + } + } + + return $outcomes; + } + + /** + * The authenticated principal's current rate limit status. Reading it + * consumes no points. + * + * @return array{limit: int, remaining: int, reset: int, used: int} + */ + public function getRateLimit(): array + { + $response = $this->call(self::METHOD_GET, '/rate_limit', ['Authorization' => "Bearer {$this->accessToken}"]); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to get the rate limit: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + $resources = \is_array($responseBody['resources'] ?? null) ? $responseBody['resources'] : []; + $core = \is_array($resources['core'] ?? null) ? $resources['core'] : []; + + return [ + 'limit' => (int) ($core['limit'] ?? 0), + 'remaining' => (int) ($core['remaining'] ?? 0), + 'reset' => (int) ($core['reset'] ?? 0), + 'used' => (int) ($core['used'] ?? 0), + ]; + } + + /** + * Synchronize one ref of a mirrored repository from its upstream source. + * Repositories that do not pull from an upstream are rejected. + * + * @param string $ref Full ref name, e.g. 'refs/heads/main' + * @param bool $wait Block until synced or the ~2 minute wait budget expires + * @param string $sha Optional commit to wait for instead of the ref tip + * @return bool True when the sync target is known satisfied (HTTP 200), false while still pending (HTTP 202) + */ + public function syncMirror(string $owner, string $repositoryName, string $ref, bool $wait = false, string $sha = ''): bool + { + $params = ['ref' => $ref, 'wait' => $wait]; + if ($sha !== '') { + $params['sha'] = $sha; + } + + $response = $this->call( + self::METHOD_POST, + $this->repositoryPath($owner, $repositoryName) . ':syncMirror', + ['Authorization' => "Bearer {$this->accessToken}"], + $params + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to sync mirror: HTTP {$statusCode}", (int) $statusCode); + } + + return $statusCode === 200; + } + + /** + * Collects every page of a token-paginated collection. + * + * @param array $params + * @return array + */ + protected function collectPages(string $path, string $field, array $params = [], string $authorization = ''): array + { + $authorization = $authorization !== '' ? $authorization : "Bearer {$this->accessToken}"; + + $items = []; + $pageToken = ''; + + do { + $query = \array_merge($params, ['pageSize' => 100]); + if ($pageToken !== '') { + $query['pageToken'] = $pageToken; + } + + $response = $this->call(self::METHOD_GET, $path, ['Authorization' => $authorization], $query); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to list {$field}: HTTP {$statusCode}", (int) $statusCode); + } + + $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; + $items = \array_merge($items, \is_array($responseBody[$field] ?? null) ? $responseBody[$field] : []); + $pageToken = \strval($responseBody['nextPageToken'] ?? ''); + } while ($pageToken !== ''); + + return $items; + } + + protected function repositoryPath(string $owner, string $repositoryName): string + { + return '/repos/' . \rawurlencode($owner) . '/' . \rawurlencode($repositoryName); + } + + /** + * Encodes a ref for use inside a URL path while keeping the slashes that + * separate its segments. + */ + protected function encodeRef(string $ref): string + { + return \str_replace('%2F', '/', \rawurlencode($ref)); + } +} diff --git a/tests/VCS/Adapter/OriginTest.php b/tests/VCS/Adapter/OriginTest.php new file mode 100644 index 00000000..fe55c920 --- /dev/null +++ b/tests/VCS/Adapter/OriginTest.php @@ -0,0 +1,629 @@ + */ + protected static array $supportedWebhookScopes = [Origin::WEBHOOK_SCOPE_INSTALLATION]; + + protected static string $eventHeader = 'webhook-event-type'; + protected static string $signatureHeader = 'webhook-signature'; + + protected static string $pushEventName = 'repository.pushed'; + protected static string $pullRequestEventName = 'pull_request.created'; + + // Origin's partner API has no visibility flags, archive downloads, + // commit statuses, language statistics, user lookup, per-repository + // webhooks, or namespace listing. Repository deletion rides the Cursor + // web app's own API. + protected static bool $reportsRepositoryVisibility = false; + protected static bool $supportsRepositoryArchives = false; + protected static bool $supportsCommitStatuses = false; + protected static bool $supportsCommitStatusLookup = false; + protected static bool $supportsRepositoryLanguages = false; + protected static bool $supportsUserLookup = false; + protected static bool $supportsNamespaceListing = false; + protected static bool $supportsWebhookDelivery = false; + protected static bool $resolvesOwnerFromRepositoryId = false; + + // Push deliveries carry ref updates, not per-commit file lists, and + // commits report plain git identities without linked accounts + protected static bool $reportsAffectedFilesInPushEvent = false; + protected static bool $reportsCommitAuthorAvatar = false; + protected static bool $reportsCommitAuthorUrl = false; + + protected function setupAdapter(): void + { + $privateKey = \str_replace('\\n', "\n", System::getEnv('TESTS_ORIGIN_PRIVATE_KEY') ?? ''); + $appId = System::getEnv('TESTS_ORIGIN_APP_IDENTIFIER') ?? ''; + static::$installationId = System::getEnv('TESTS_ORIGIN_INSTALLATION_ID') ?? ''; + + if (empty($privateKey) || empty($appId) || empty(static::$installationId)) { + $this->markTestSkipped('Origin app credentials not configured'); + } + + $adapter = new Origin(new Cache(new None())); + $adapter->initializeVariables( + installationId: static::$installationId, + privateKey: $privateKey, + appId: $appId, + accessToken: '', + refreshToken: '' + ); + + if (empty(static::$owner)) { + static::$owner = $adapter->getOwnerName(static::$installationId); + } + + $this->vcsAdapter = $adapter; + } + + /** + * Origin reports the repository owner as a reference carrying the slug. + * + * @param array $repository + */ + protected function ownerOf(array $repository): string + { + $this->assertArrayHasKey('owner', $repository); + $this->assertIsArray($repository['owner']); + $this->assertArrayHasKey('slug', $repository['owner']); + + return (string) $repository['owner']['slug']; + } + + /** + * Origin reports timestamps in camelCase. + * + * @param array $repository + */ + protected function assertPushedAt(array $repository): void + { + $this->assertArrayHasKey('pushedAt', $repository); + $this->assertNotFalse( + \strtotime((string) $repository['pushedAt']), + 'pushedAt is not a parseable timestamp' + ); + } + + /** + * Origin signs webhooks with an Ed25519 key rather than an HMAC secret; + * $secret carries the base64-encoded libsodium secret key. + */ + protected function signWebhookPayload(string $payload, string $secret): string + { + $secretKey = \base64_decode($secret, true); + if ($secretKey === false || \strlen($secretKey) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) { + $this->fail('The Origin webhook signer needs a base64-encoded Ed25519 secret key.'); + } + + // Origin signs the lowercase hex SHA-256 digest of ".." + $signature = \sodium_crypto_sign_detached(\hash('sha256', $payload), $secretKey); + + return 'v1ed,' . \base64_encode($signature); + } + + /** + * The generic HMAC round-trip does not apply: Origin verification takes + * the delivery's signed content and Origin's Ed25519 public key. + */ + public function testValidateWebhookEvent(): void + { + $keyPair = \sodium_crypto_sign_keypair(); + $secret = \base64_encode(\sodium_crypto_sign_secretkey($keyPair)); + $publicKey = \base64_encode(\sodium_crypto_sign_publickey($keyPair)); + + $payload = 'whd_0123456789.1755500000.{"deliveryId":"whd_0123456789"}'; + + $this->assertTrue( + $this->vcsAdapter->validateWebhookEvent($payload, $this->signWebhookPayload($payload, $secret), $publicKey) + ); + $this->assertFalse($this->vcsAdapter->validateWebhookEvent($payload, 'not-the-signature', $publicKey)); + + // A signature by a different key must not verify + $otherSecret = \base64_encode(\sodium_crypto_sign_secretkey(\sodium_crypto_sign_keypair())); + $this->assertFalse( + $this->vcsAdapter->validateWebhookEvent($payload, $this->signWebhookPayload($payload, $otherSecret), $publicKey) + ); + + // Tampered content must not verify either + $this->assertFalse( + $this->vcsAdapter->validateWebhookEvent($payload . 'tampered', $this->signWebhookPayload($payload, $secret), $publicKey) + ); + } + + public function testValidateWebhookEventAcceptsPemPublicKey(): void + { + $keyPair = \sodium_crypto_sign_keypair(); + $secret = \base64_encode(\sodium_crypto_sign_secretkey($keyPair)); + + // SPKI wrapping of a raw Ed25519 public key + $der = \hex2bin('302a300506032b6570032100') . \sodium_crypto_sign_publickey($keyPair); + $pem = "-----BEGIN PUBLIC KEY-----\n" . \chunk_split(\base64_encode((string) $der), 64, "\n") . '-----END PUBLIC KEY-----'; + + $payload = 'whd_0123456789.1755500000.{"deliveryId":"whd_0123456789"}'; + + $this->assertTrue( + $this->vcsAdapter->validateWebhookEvent($payload, $this->signWebhookPayload($payload, $secret), $pem) + ); + } + + /** + * Build a delivery the way Origin wraps a push: an envelope whose event + * payload carries the repository reference and one ref update per pushed + * ref. File lists never travel in push deliveries, so $added, $removed + * and $modified stay unused. + * + * @param array $added + * @param array $removed + * @param array $modified + */ + protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string + { + return (string) \json_encode([ + 'deliveryId' => 'whd_0123456789', + 'appId' => 'app_0123456789', + 'installationId' => 'i_0123456789', + 'event' => [ + 'id' => 'evt_0123456789', + 'type' => 'repository.pushed', + 'eventTime' => '2026-08-18T10:00:00Z', + 'payload' => [ + 'repository' => [ + 'id' => self::EVENT_REPOSITORY_ID, + 'name' => self::EVENT_REPOSITORY_NAME, + 'owner' => ['slug' => self::EVENT_OWNER, 'id' => 'ns_0123456789'], + ], + 'refUpdates' => [[ + 'ref' => 'refs/heads/' . $branch, + 'before' => $created ? \str_repeat('0', 40) : 'abc123', + 'after' => $deleted ? \str_repeat('0', 40) : self::EVENT_COMMIT_HASH, + 'created' => $created, + 'deleted' => $deleted, + 'forced' => false, + 'headCommit' => $deleted ? null : [ + 'sha' => self::EVENT_COMMIT_HASH, + 'author' => ['name' => self::EVENT_AUTHOR_NAME, 'email' => self::EVENT_AUTHOR_EMAIL], + 'committer' => ['name' => self::EVENT_AUTHOR_NAME, 'email' => self::EVENT_AUTHOR_EMAIL], + 'message' => self::EVENT_COMMIT_MESSAGE, + ], + ]], + 'refUpdatesCount' => 1, + 'pushedAt' => '2026-08-18T10:00:00Z', + 'pusher' => ['user' => ['id' => 'user_0123456789', 'email' => self::EVENT_AUTHOR_EMAIL]], + ], + ], + ]); + } + + /** + * Build a delivery the way Origin announces an opened pull request. The + * event type carries the action; there is no separate action field. + */ + protected function pullRequestPayload(bool $external = false): string + { + return (string) \json_encode([ + 'deliveryId' => 'whd_0123456789', + 'appId' => 'app_0123456789', + 'installationId' => 'i_0123456789', + 'event' => [ + 'id' => 'evt_0123456789', + 'type' => 'pull_request.created', + 'eventTime' => '2026-08-18T10:00:00Z', + 'payload' => [ + 'pullRequest' => [ + 'id' => 'pr_0123456789', + 'number' => (string) self::EVENT_PULL_REQUEST_NUMBER, + 'state' => 'open', + 'draft' => false, + 'merged' => false, + 'title' => 'Test PR', + 'body' => '', + 'head' => ['ref' => 'refs/heads/' . self::EVENT_HEAD_BRANCH, 'sha' => self::EVENT_COMMIT_HASH], + 'base' => ['ref' => 'refs/heads/' . static::$defaultBranch, 'sha' => 'abc123'], + 'author' => ['user' => ['id' => 'user_0123456789', 'email' => self::EVENT_AUTHOR_EMAIL]], + ], + 'repository' => [ + 'id' => self::EVENT_REPOSITORY_ID, + 'name' => self::EVENT_REPOSITORY_NAME, + 'owner' => ['slug' => self::EVENT_OWNER, 'id' => 'ns_0123456789'], + ], + ], + ], + ]); + } + + /** + * Origin pull requests always open from a branch of the same repository - + * there is no fork model - so no delivery can describe an external one. + */ + public function testGetEventPullRequestDetectsExternal(): void + { + $events = $this->vcsAdapter->getEvents(static::$pullRequestEventName, $this->pullRequestPayload(external: true)); + $this->assertCount(1, $events); + $this->assertFalse($events[0]['external']); + } + + public function testGetEventPullRequestMapsLifecycleActions(): void + { + $payload = \json_decode($this->pullRequestPayload(), true); + $this->assertIsArray($payload); + + $expected = [ + 'pull_request.created' => 'opened', + 'pull_request.head_ref.pushed' => 'synchronize', + 'pull_request.base_ref.updated' => 'edited', + 'pull_request.metadata.updated' => 'edited', + 'pull_request.closed' => 'closed', + 'pull_request.merged' => 'closed', + 'pull_request.reopened' => 'reopened', + 'pull_request.published' => 'ready_for_review', + ]; + + foreach ($expected as $type => $action) { + $payload['event']['type'] = $type; + $events = $this->vcsAdapter->getEvents($type, (string) \json_encode($payload)); + $this->assertCount(1, $events); + $this->assertSame($action, $events[0]['action'], "Unexpected action for {$type}"); + } + + // Comment, review and reviewer deliveries are not lifecycle events + $payload['event']['type'] = 'pull_request.comment.created'; + $this->assertSame([], $this->vcsAdapter->getEvents('pull_request.comment.created', (string) \json_encode($payload))); + } + + public function testGetEventPushWithMultipleRefUpdates(): void + { + $payload = \json_decode($this->pushPayload(static::$defaultBranch), true); + $this->assertIsArray($payload); + + $secondRef = $payload['event']['payload']['refUpdates'][0]; + $secondRef['ref'] = 'refs/heads/feature-branch'; + $payload['event']['payload']['refUpdates'][] = $secondRef; + $payload['event']['payload']['refUpdatesCount'] = 2; + + $events = $this->vcsAdapter->getEvents(static::$pushEventName, (string) \json_encode($payload)); + + $this->assertCount(2, $events); + $this->assertSame(static::$defaultBranch, $events[0]['branch']); + $this->assertSame('feature-branch', $events[1]['branch']); + } + + public function testGetEventInstallation(): void + { + $payload = (string) \json_encode([ + 'deliveryId' => 'whd_0123456789', + 'appId' => 'app_0123456789', + 'installationId' => 'i_0123456789', + 'event' => [ + 'id' => 'evt_0123456789', + 'type' => 'installation.deleted', + 'eventTime' => '2026-08-18T10:00:00Z', + 'payload' => [ + 'installation' => [ + 'id' => 'i_0123456789', + 'target' => ['slug' => 'test-workspace', 'id' => 'ns_0123456789'], + 'repoSelectionMode' => 'all', + ], + 'app' => ['id' => 'app_0123456789', 'slug' => 'test-app'], + ], + ], + ]); + + $events = $this->vcsAdapter->getEvents('installation.deleted', $payload); + $this->assertIsArray($events); + $this->assertCount(1, $events); + $result = $events[0]; + + $this->assertSame('deleted', $result['action']); + $this->assertSame('i_0123456789', $result['installationId']); + $this->assertSame('test-workspace', $result['userName']); + } + + /** + * The adapter under test, with its Origin-specific surface visible. + */ + private function origin(): Origin + { + \assert($this->vcsAdapter instanceof Origin); + + return $this->vcsAdapter; + } + + /** + * @return array{string, int} Repository name and pull request number + */ + private function createRepositoryWithPullRequest(string $prefix): array + { + $repositoryName = $prefix . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $this->getLatestCommitEventually($repositoryName); + $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'feature-branch', static::$defaultBranch); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'feature.txt', 'feature content', 'Add feature', 'feature-branch'); + + $pullRequest = $this->vcsAdapter->createPullRequest( + static::$owner, + $repositoryName, + 'Test PR', + 'feature-branch', + static::$defaultBranch, + 'Test PR description' + ); + + return [$repositoryName, $this->pullRequestNumberOf($pullRequest)]; + } + + public function testUpdatePullRequest(): void + { + [$repositoryName, $prNumber] = $this->createRepositoryWithPullRequest('test-update-pr-'); + + try { + $updated = $this->origin()->updatePullRequest(static::$owner, $repositoryName, $prNumber, title: 'Updated title', body: 'Updated body'); + $this->assertSame('Updated title', $updated['title']); + $this->assertSame('Updated body', $updated['body']); + + $closed = $this->origin()->updatePullRequest(static::$owner, $repositoryName, $prNumber, state: 'closed'); + $this->assertSame('closed', $closed['state']); + + $reopened = $this->origin()->updatePullRequest(static::$owner, $repositoryName, $prNumber, state: 'open'); + $this->assertSame('open', $reopened['state']); + } finally { + $this->discardRepositories($repositoryName); + } + } + + public function testMergePullRequest(): void + { + [$repositoryName, $prNumber] = $this->createRepositoryWithPullRequest('test-merge-pr-'); + + try { + $result = $this->origin()->mergePullRequest(static::$owner, $repositoryName, $prNumber); + + $this->assertNotEmpty($result['mergeCommitSha']); + $this->assertContains($prNumber, $result['mergedPullNumbers']); + $this->assertTrue($result['pullRequest']['merged']); + + // The merged file has to be readable from the base branch + $this->assertEventually(function () use ($repositoryName) { + $content = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'feature.txt', static::$defaultBranch); + $this->assertSame('feature content', $content['content']); + }); + } finally { + $this->discardRepositories($repositoryName); + } + } + + public function testListPullRequests(): void + { + [$repositoryName, $prNumber] = $this->createRepositoryWithPullRequest('test-list-prs-'); + + try { + $open = $this->origin()->listPullRequests(static::$owner, $repositoryName); + $this->assertContains($prNumber, \array_column($open, 'number')); + + $this->assertSame([], $this->origin()->listPullRequests(static::$owner, $repositoryName, 'closed')); + + $byHead = $this->origin()->listPullRequests(static::$owner, $repositoryName, 'open', 'feature-branch'); + $this->assertContains($prNumber, \array_column($byHead, 'number')); + } finally { + $this->discardRepositories($repositoryName); + } + } + + public function testListPullRequestCommentsAndCommits(): void + { + [$repositoryName, $prNumber] = $this->createRepositoryWithPullRequest('test-pr-comments-commits-'); + + try { + $commentId = $this->vcsAdapter->createComment(static::$owner, $repositoryName, $prNumber, 'A listed comment'); + + $comments = $this->origin()->listPullRequestComments(static::$owner, $repositoryName, $prNumber); + $this->assertContains($commentId, \array_column($comments, 'id')); + + $commits = $this->origin()->listPullRequestCommits(static::$owner, $repositoryName, $prNumber); + $this->assertNotEmpty($commits); + $messages = \array_map(fn ($commit) => $commit['commit']['message'] ?? '', $commits); + $this->assertNotEmpty(\array_filter($messages, fn ($message) => \str_starts_with($message, 'Add feature'))); + } finally { + $this->discardRepositories($repositoryName); + } + } + + public function testPullRequestReviews(): void + { + [$repositoryName, $prNumber] = $this->createRepositoryWithPullRequest('test-pr-reviews-'); + + try { + // The app authored the pull request, and authors cannot approve + // their own change, so exercise request_changes instead + $review = $this->origin()->createPullRequestReview(static::$owner, $repositoryName, $prNumber, 'request_changes', 'Needs work'); + $this->assertNotEmpty($review['id']); + $this->assertSame('request_changes', $review['verdict']); + + $reviews = $this->origin()->listPullRequestReviews(static::$owner, $repositoryName, $prNumber); + $this->assertContains($review['id'], \array_column($reviews, 'id')); + + $updated = $this->origin()->updatePullRequestReview(static::$owner, $repositoryName, $prNumber, $review['id'], 'Needs more work'); + $this->assertSame('Needs more work', $updated['body']); + + $dismissed = $this->origin()->dismissPullRequestReview(static::$owner, $repositoryName, $prNumber, $review['id'], 'Handled offline'); + $this->assertArrayHasKey('dismissal', $dismissed); + } finally { + $this->discardRepositories($repositoryName); + } + } + + public function testListCommitsAndCommitFiles(): void + { + $repositoryName = 'test-list-commits-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test', 'First commit'); + $this->getLatestCommitEventually($repositoryName); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'second.txt', 'second', 'Second commit'); + + $commits = []; + $this->assertEventually(function () use (&$commits, $repositoryName) { + $commits = $this->origin()->listCommits(static::$owner, $repositoryName); + $this->assertCount(2, $commits); + }); + + $this->assertStringStartsWith('Second commit', $commits[0]['commitMessage']); + $this->assertNotEmpty($commits[0]['commitHash']); + $this->assertNotEmpty($commits[0]['commitUrl']); + + $files = $this->origin()->listCommitFiles(static::$owner, $repositoryName, $commits[0]['commitHash']); + $this->assertContains('second.txt', \array_column($files, 'filename')); + } finally { + $this->discardRepositories($repositoryName); + } + } + + public function testCompareCommits(): void + { + $repositoryName = 'test-compare-commits-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test', 'First commit'); + $first = $this->getLatestCommitEventually($repositoryName)['commitHash']; + + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'second.txt', 'second', 'Second commit'); + $second = ''; + $this->assertEventually(function () use (&$second, $repositoryName, $first) { + $second = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch)['commitHash']; + $this->assertNotSame($first, $second); + }); + + $comparison = $this->origin()->compareCommits(static::$owner, $repositoryName, $first, $second); + $this->assertSame('ahead', $comparison['status']); + $this->assertSame(1, $comparison['aheadBy']); + + $this->assertSame('identical', $this->origin()->compareCommits(static::$owner, $repositoryName, $first, $first)['status']); + } finally { + $this->discardRepositories($repositoryName); + } + } + + public function testGetBlob(): void + { + $repositoryName = 'test-get-blob-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Blob content'); + + $file = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'README.md'); + $blob = $this->origin()->getBlob(static::$owner, $repositoryName, (string) $file['sha']); + + $this->assertSame('# Blob content', $blob['content']); + $this->assertSame($file['sha'], $blob['sha']); + } finally { + $this->discardRepositories($repositoryName); + } + } + + public function testBatchGetRepositoryContents(): void + { + $repositoryName = 'test-batch-get-contents-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'src/main.php', 'assertEventually(function () use (&$contents, $repositoryName) { + $contents = $this->origin()->batchGetRepositoryContents( + static::$owner, + $repositoryName, + ['README.md', 'src/main.php', 'missing.txt'] + ); + $this->assertSame('# Test', $contents['README.md'] ?? null); + }); + + $this->assertSame('assertNull($contents['missing.txt']); + } finally { + $this->discardRepositories($repositoryName); + } + } + + public function testBatchUpsertAndListCheckRuns(): void + { + $repositoryName = 'test-batch-check-runs-' . \uniqid(); + $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); + + try { + $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); + $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; + + $checkRuns = $this->origin()->batchUpsertCheckRuns(static::$owner, $repositoryName, $commitHash, 'ci', [ + ['name' => 'ci/build', 'status' => 'in_progress'], + ['name' => 'ci/lint', 'conclusion' => 'success', 'title' => 'Lint passed', 'summary' => 'No issues.'], + ]); + + $this->assertCount(2, $checkRuns); + $this->assertSame('in_progress', $checkRuns[0]['status']); + $this->assertSame('success', $checkRuns[1]['conclusion']); + + $forCommit = $this->origin()->listCheckRunsForCommit(static::$owner, $repositoryName, $commitHash); + $names = \array_column($forCommit, 'name'); + $this->assertContains('ci/build', $names); + $this->assertContains('ci/lint', $names); + + $suiteId = (string) $checkRuns[0]['check_suite_id']; + $this->assertNotEmpty($suiteId); + $this->assertSame('ci', $this->origin()->getCheckSuite(static::$owner, $repositoryName, $suiteId)['key']); + + $forSuite = $this->origin()->listCheckRunsForSuite(static::$owner, $repositoryName, $suiteId); + $this->assertCount(2, $forSuite); + } finally { + $this->discardRepositories($repositoryName); + } + } + + public function testGetRateLimit(): void + { + $rateLimit = $this->origin()->getRateLimit(); + + $this->assertGreaterThan(0, $rateLimit['limit']); + $this->assertGreaterThanOrEqual(0, $rateLimit['remaining']); + $this->assertGreaterThan(0, $rateLimit['reset']); + } + + public function testGetAuthenticatedApp(): void + { + $app = $this->origin()->getAuthenticatedApp(); + + $this->assertNotEmpty($app['id']); + $this->assertNotEmpty($app['slug']); + } + + public function testListInstallations(): void + { + $installations = $this->origin()->listInstallations(); + + $this->assertContains(static::$installationId, \array_column($installations, 'id')); + } + + public function testListWebhookDeliveries(): void + { + $page = $this->origin()->listWebhookDeliveries(); + + $this->assertIsArray($page['deliveries']); + $this->assertIsString($page['nextPageToken']); + } +} diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index ab571542..2b140a6c 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -133,6 +133,24 @@ abstract class Base extends TestCase protected static bool $supportsNamespaceListing = true; + /** + * Whether the provider can delete a repository at all. Origin has no + * deletion endpoint, which also leaves test repositories behind for a + * manual sweep. + */ + protected static bool $supportsRepositoryDeletion = true; + + /** + * Whether repositories carry a visibility flag. Origin scopes visibility + * to the owning workspace instead of reporting one per repository. + */ + protected static bool $reportsRepositoryVisibility = true; + + /** + * Whether the provider can hand out an archive download URL. + */ + protected static bool $supportsRepositoryArchives = true; + /** * Whether a push event names the files it touched. Bitbucket's payload * carries no file lists at all. @@ -373,6 +391,11 @@ protected function getLatestCommitEventually(string $repositoryName): array */ protected function discardRepositories(string ...$repositoryNames): void { + // A provider without a deletion API leaves nothing for teardown to do + if (!static::$supportsRepositoryDeletion) { + return; + } + $failures = []; foreach ($repositoryNames as $repositoryName) { @@ -446,11 +469,15 @@ public function testCreateRepository(): void $this->assertSame($repositoryName, $result['name']); $this->assertPushedAt($result); - $this->assertFalse($this->isPrivate($result), 'createRepository() reported the new repository as private'); + if (static::$reportsRepositoryVisibility) { + $this->assertFalse($this->isPrivate($result), 'createRepository() reported the new repository as private'); + } $this->assertSame($this->ownerPath(), $this->ownerOf($result)); $fetched = $this->vcsAdapter->getRepository(static::$owner, $repositoryName); - $this->assertFalse($this->isPrivate($fetched), 'getRepository() reported the new repository as private'); + if (static::$reportsRepositoryVisibility) { + $this->assertFalse($this->isPrivate($fetched), 'getRepository() reported the new repository as private'); + } $this->assertSame($this->ownerPath(), $this->ownerOf($fetched)); } finally { $this->discardRepositories($repositoryName); @@ -459,6 +486,8 @@ public function testCreateRepository(): void public function testCreatePrivateRepository(): void { + $this->skipUnlessSupported(static::$reportsRepositoryVisibility, 'repository visibility'); + $repositoryName = 'test-create-private-' . \uniqid(); $result = $this->vcsAdapter->createRepository(static::$owner, $repositoryName, true); @@ -506,6 +535,8 @@ public function testGetRepositoryWithNonExistingOwner(): void public function testDeleteRepository(): void { + $this->skipUnlessSupported(static::$supportsRepositoryDeletion, 'deleting repositories'); + $repositoryName = 'test-delete-repository-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -515,6 +546,8 @@ public function testDeleteRepository(): void public function testDeleteRepositoryTwiceFails(): void { + $this->skipUnlessSupported(static::$supportsRepositoryDeletion, 'deleting repositories'); + $repositoryName = 'test-delete-repository-twice-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); @@ -529,6 +562,8 @@ public function testDeleteRepositoryTwiceFails(): void public function testDeleteNonExistingRepositoryFails(): void { + $this->skipUnlessSupported(static::$supportsRepositoryDeletion, 'deleting repositories'); + try { $this->vcsAdapter->deleteRepository(static::$owner, 'non-existing-repo-' . \uniqid()); $this->fail('Deleting a non existing repository should have thrown'); @@ -1583,6 +1618,8 @@ public function testValidateWebhookEvent(): void public function testGetRepositoryPresignedUrl(): void { + $this->skipUnlessSupported(static::$supportsRepositoryArchives, 'archive downloads'); + $repositoryName = 'test-presigned-url-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1606,6 +1643,8 @@ public function testGetRepositoryPresignedUrl(): void public function testGetRepositoryPresignedUrlWithInvalidFormat(): void { + $this->skipUnlessSupported(static::$supportsRepositoryArchives, 'archive downloads'); + $this->expectException(Exception::class); $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, 'some-repo', static::$defaultBranch, 'invalid'); } @@ -1914,7 +1953,7 @@ public function testCreateCheckRun(): void $this->assertNotEmpty($fetched['url']); $this->assertNotEmpty($fetched['html_url']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testCreateCheckRunWithInvalidRepository(): void @@ -1940,7 +1979,7 @@ public function testGetCheckRunWithInvalidId(): void $this->expectException(\Exception::class); $this->vcsAdapter->getCheckRun(static::$owner, $repositoryName, '999999999'); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testCreateTwoCheckRunsOnSameCommit(): void @@ -1980,7 +2019,7 @@ public function testCreateTwoCheckRunsOnSameCommit(): void $this->assertEquals('ci/build', $first['name']); $this->assertEquals('ci/build', $second['name']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testCreateCheckRunsWithSameNameOnDifferentCommits(): void @@ -2023,7 +2062,7 @@ public function testCreateCheckRunsWithSameNameOnDifferentCommits(): void $this->assertEquals('ci/build', $first['name']); $this->assertEquals('ci/build', $second['name']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testCreateCheckRunCompleted(): void @@ -2061,7 +2100,7 @@ public function testCreateCheckRunCompleted(): void $this->assertEquals('Build passed', $checkRun['output']['title']); $this->assertEquals('All checks passed successfully.', $checkRun['output']['summary']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testUpdateCheckRun(): void @@ -2103,7 +2142,7 @@ public function testUpdateCheckRun(): void $this->assertEquals('completed', $updated['status']); $this->assertEquals('neutral', $updated['conclusion']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testUpdateCheckRunWithInvalidRepository(): void @@ -2134,7 +2173,7 @@ public function testUpdateCheckRunWithInvalidId(): void conclusion: 'success', ); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testUpdateCheckRunWithMissingConclusion(): void @@ -2166,7 +2205,7 @@ public function testUpdateCheckRunWithMissingConclusion(): void status: 'completed', ); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -2224,7 +2263,7 @@ public function testListRepositoryContentsRootSentinels(): void $this->assertEquals(array_column($empty, 'name'), array_column($dotSlash, 'name')); $this->assertEquals(array_column($empty, 'name'), array_column($repeatedDotSlash, 'name')); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testGetRepositoryContentRootSentinelPrefix(): void @@ -2242,7 +2281,7 @@ public function testGetRepositoryContentRootSentinelPrefix(): void $this->assertEquals($direct['content'], $prefixed['content']); $this->assertEquals($direct['content'], $repeatedPrefix['content']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testListRepositoryContentsMalformedNestedPath(): void @@ -2261,7 +2300,7 @@ public function testListRepositoryContentsMalformedNestedPath(): void $this->assertEquals(array_column($clean, 'name'), array_column($embeddedDot, 'name')); $this->assertEquals(array_column($clean, 'name'), array_column($doubleSlash, 'name')); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } @@ -2314,11 +2353,12 @@ public function testGetCommitAuthorAvatar(): void $this->assertNotEmpty($commit['commitAuthorAvatar']); $this->assertStringContainsString(static::$avatarDomain, $commit['commitAuthorAvatar']); } finally { - $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); + $this->discardRepositories($repositoryName); } } public function testGetRepositoryAfterDeleteFails(): void { + $this->skipUnlessSupported(static::$supportsRepositoryDeletion, 'deleting repositories'); $this->skipUnlessSupported(static::$deletesRepositoriesSynchronously, 'deleting a repository straight away'); $repositoryName = 'test-get-deleted-repository-' . \uniqid(); From ebef3d356286dae1ae0a529eb83c85fcc20e658d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 18 Aug 2026 14:22:19 +0200 Subject: [PATCH 02/14] feat: ride commit statuses on Origin check runs Origin models CI feedback as check runs only, so updateCommitStatus() upserts a check run keyed on the status context - repeated updates for one context land on one run - and getCommitStatuses() reads the commit's check runs back in commit-status shape. Consumers that only speak commit statuses now work against Origin unchanged. Co-Authored-By: Claude Fable 5 --- src/VCS/Adapter/Git/Origin.php | 78 ++++++++++++++++++++++++++++++-- tests/VCS/Adapter/OriginTest.php | 8 ++-- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/VCS/Adapter/Git/Origin.php b/src/VCS/Adapter/Git/Origin.php index e8c6b430..68c17350 100644 --- a/src/VCS/Adapter/Git/Origin.php +++ b/src/VCS/Adapter/Git/Origin.php @@ -1128,22 +1128,93 @@ public function listTags(string $owner, string $repositoryName, string $search = /** * Updates status check of each commit + * state can be one of: error, failure, pending, success * - * Origin models CI feedback as check runs only. + * Origin models CI feedback as check runs only, so this rides the check + * run upsert with the status context as the stable key - repeated updates + * for one context land on one run, which is Origin's intended model. */ public function updateCommitStatus(string $repositoryName, string $SHA, string $owner, string $state, string $description = '', string $target_url = '', string $context = ''): void { - throw new Exception('updateCommitStatus() is not supported by Origin. Use createCheckRun() instead.'); + $context = !empty($context) ? $context : 'default'; + + $checkRun = [ + 'key' => $context, + 'name' => $context, + 'status' => $state === 'pending' ? 'in_progress' : 'completed', + // The context is the run's identity, so its attempt id can stay stable too + 'externalId' => $context, + 'externalUpdatedAt' => $this->rfc3339Now(), + ]; + + if ($state !== 'pending') { + $checkRun['conclusion'] = $state === 'success' ? 'success' : 'failure'; + $checkRun['completedAt'] = \gmdate('Y-m-d\TH:i:s\Z'); + } + + if (!empty($description)) { + $checkRun['output'] = ['title' => \mb_strimwidth($description, 0, 255), 'summary' => $description]; + } + + if (!empty($target_url)) { + $checkRun['detailsUrl'] = $target_url; + } + + $response = $this->call( + self::METHOD_POST, + $this->repositoryPath($owner, $repositoryName) . '/check-runs', + ['Authorization' => "Bearer {$this->accessToken}"], + [ + 'headSha' => $SHA, + 'checkSuite' => [ + 'key' => $context, + 'name' => $context, + 'externalId' => $context, + ], + 'checkRun' => $checkRun, + ] + ); + + $statusCode = $response['headers']['status-code'] ?? 0; + if ($statusCode >= 400) { + throw new Exception("Failed to update commit status: HTTP {$statusCode}", (int) $statusCode); + } } /** * Get commit statuses * + * Reports the commit's check runs in commit-status shape, mirroring + * updateCommitStatus()'s mapping. + * * @return array */ public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array { - throw new Exception('getCommitStatuses() is not supported by Origin. Use getCheckRun() instead.'); + $statuses = []; + foreach ($this->listCheckRunsForCommit($owner, $repositoryName, $commitHash) as $checkRun) { + if (!\is_array($checkRun)) { + continue; + } + + $conclusion = \strval($checkRun['conclusion'] ?? ''); + $state = match (true) { + ($checkRun['status'] ?? '') !== 'completed' => 'pending', + \in_array($conclusion, ['success', 'neutral', 'skipped'], true) => 'success', + default => 'failure', + }; + + $output = \is_array($checkRun['output'] ?? null) ? $checkRun['output'] : []; + + $statuses[] = [ + 'state' => $state, + 'description' => \strval($output['title'] ?? ''), + 'target_url' => \strval($checkRun['details_url'] ?? ''), + 'context' => \strval($checkRun['key'] ?? $checkRun['name'] ?? ''), + ]; + } + + return $statuses; } /** @@ -1418,6 +1489,7 @@ protected function normalizeCheckRun(array $checkRun, string $owner, string $rep 'html_url' => !empty($sha) ? $this->getCommitUrl($owner, $repositoryName, $sha) : $this->getRepositoryUrl($owner, $repositoryName), 'started_at' => !empty($checkRun['startedAt']) ? \strval($checkRun['startedAt']) : null, 'completed_at' => !empty($checkRun['completedAt']) ? \strval($checkRun['completedAt']) : null, + 'details_url' => \strval($checkRun['detailsUrl'] ?? ''), 'external_id' => \strval($checkRun['externalId'] ?? ''), 'key' => \strval($checkRun['key'] ?? ''), 'check_suite_id' => \strval($checkSuite['id'] ?? ''), diff --git a/tests/VCS/Adapter/OriginTest.php b/tests/VCS/Adapter/OriginTest.php index fe55c920..baa64932 100644 --- a/tests/VCS/Adapter/OriginTest.php +++ b/tests/VCS/Adapter/OriginTest.php @@ -23,13 +23,11 @@ class OriginTest extends Base protected static string $pullRequestEventName = 'pull_request.created'; // Origin's partner API has no visibility flags, archive downloads, - // commit statuses, language statistics, user lookup, per-repository - // webhooks, or namespace listing. Repository deletion rides the Cursor - // web app's own API. + // language statistics, user lookup, per-repository webhooks, or namespace + // listing. Repository deletion rides the Cursor web app's own API, and + // commit statuses ride the check run upsert. protected static bool $reportsRepositoryVisibility = false; protected static bool $supportsRepositoryArchives = false; - protected static bool $supportsCommitStatuses = false; - protected static bool $supportsCommitStatusLookup = false; protected static bool $supportsRepositoryLanguages = false; protected static bool $supportsUserLookup = false; protected static bool $supportsNamespaceListing = false; From cbf57939aa7f0d40c5e6ab9120e13b3247bd767a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 18 Aug 2026 14:28:48 +0200 Subject: [PATCH 03/14] feat: report archive support so consumers can package sources themselves Co-Authored-By: Claude Fable 5 --- src/VCS/Adapter/Git.php | 10 ++++++++++ src/VCS/Adapter/Git/Origin.php | 9 +++++++++ 2 files changed, 19 insertions(+) diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index f8816925..7ec5a2cc 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -99,6 +99,16 @@ abstract public function createWebhook(string $owner, string $repositoryName, st */ abstract public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array; + /** + * Whether the provider can hand out an archive download URL at all, so a + * consumer can arrange its own source packaging before calling + * getRepositoryPresignedUrl() just to catch it throwing. + */ + public function supportsRepositoryArchives(): bool + { + return true; + } + /** * Headers a caller must send with getRepositoryPresignedUrl() to reach a * private repository. diff --git a/src/VCS/Adapter/Git/Origin.php b/src/VCS/Adapter/Git/Origin.php index 68c17350..28e9344f 100644 --- a/src/VCS/Adapter/Git/Origin.php +++ b/src/VCS/Adapter/Git/Origin.php @@ -1696,6 +1696,15 @@ public function getCommit(string $owner, string $repositoryName, string $commitH ]; } + /** + * Origin offers no archive downloads; consumers package sources + * themselves, over Git HTTPS. + */ + public function supportsRepositoryArchives(): bool + { + return false; + } + /** * Get latest commit of a branch * From f841df3867d6f98bfbab9faa5cd0b38a04674fb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 18 Aug 2026 15:49:34 +0200 Subject: [PATCH 04/14] fix: alias pushed_at on Origin repository objects Every other adapter reports repositories with a pushed_at timestamp, so consumers read that name - Appwrite's repository listing does, and was rendering an unknown date for every Origin repository. Alias Origin's camelCase pushedAt (falling back to updatedAt/createdAt) on every repository object the adapter returns, keeping the provider's own fields intact. Co-Authored-By: Claude Fable 5 --- src/VCS/Adapter/Git/Origin.php | 33 +++++++++++++++++++++++++++----- tests/VCS/Adapter/OriginTest.php | 14 -------------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/VCS/Adapter/Git/Origin.php b/src/VCS/Adapter/Git/Origin.php index 28e9344f..783a9f14 100644 --- a/src/VCS/Adapter/Git/Origin.php +++ b/src/VCS/Adapter/Git/Origin.php @@ -344,11 +344,34 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri } while ($pageToken !== ''); return [ - 'items' => \array_slice($repositories, ($page - 1) * $per_page, $per_page), + 'items' => \array_map( + fn ($repository) => $this->normalizeRepository(\is_array($repository) ? $repository : []), + \array_slice($repositories, ($page - 1) * $per_page, $per_page) + ), 'total' => \count($repositories), ]; } + /** + * Origin reports timestamps in camelCase; every other adapter reports + * pushed_at, so consumers read that name. Alias it rather than rename it, + * keeping the provider's own fields intact. + * + * @param array $repository + * @return array + */ + protected function normalizeRepository(array $repository): array + { + if (!\array_key_exists('pushed_at', $repository)) { + $pushedAt = $repository['pushedAt'] ?? $repository['updatedAt'] ?? $repository['createdAt'] ?? null; + if ($pushedAt !== null) { + $repository['pushed_at'] = $pushedAt; + } + } + + return $repository; + } + /** * Search fallback over the repositories the installation can access, * filtered to the requested owner and name substring. @@ -367,7 +390,7 @@ protected function searchInstallationRepositories(string $owner, int $page, int continue; } - $repositories[] = $repository; + $repositories[] = $this->normalizeRepository($repository); } return [ @@ -385,7 +408,7 @@ public function getInstallationRepository(string $repositoryName): array { foreach ($this->installationRepositories() as $repository) { if (\strtolower(\strval($repository['name'] ?? '')) === \strtolower($repositoryName)) { - return $repository; + return $this->normalizeRepository($repository); } } @@ -466,7 +489,7 @@ public function getRepository(string $owner, string $repositoryName): array throw new Exception("Failed to get repository {$repositoryName}: HTTP {$statusCode}", (int) $statusCode); } - return \is_array($response['body'] ?? null) ? $response['body'] : []; + return $this->normalizeRepository(\is_array($response['body'] ?? null) ? $response['body'] : []); } /** @@ -491,7 +514,7 @@ public function createRepository(string $owner, string $repositoryName, bool $pr throw new Exception("Creating repository {$repositoryName} failed with status code {$statusCode}", (int) $statusCode); } - return \is_array($response['body'] ?? null) ? $response['body'] : []; + return $this->normalizeRepository(\is_array($response['body'] ?? null) ? $response['body'] : []); } /** diff --git a/tests/VCS/Adapter/OriginTest.php b/tests/VCS/Adapter/OriginTest.php index baa64932..ebd89f95 100644 --- a/tests/VCS/Adapter/OriginTest.php +++ b/tests/VCS/Adapter/OriginTest.php @@ -80,20 +80,6 @@ protected function ownerOf(array $repository): string return (string) $repository['owner']['slug']; } - /** - * Origin reports timestamps in camelCase. - * - * @param array $repository - */ - protected function assertPushedAt(array $repository): void - { - $this->assertArrayHasKey('pushedAt', $repository); - $this->assertNotFalse( - \strtotime((string) $repository['pushedAt']), - 'pushedAt is not a parseable timestamp' - ); - } - /** * Origin signs webhooks with an Ed25519 key rather than an HMAC secret; * $secret carries the base64-encoded libsodium secret key. From cb6f3c1d6d72487b264070e663ade8da57cd3a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 18 Aug 2026 15:52:12 +0200 Subject: [PATCH 05/14] feat: carry Origin's error message in failed-call exceptions Origin answers every failure with a Google-RPC body whose message names the exact rule that refused the request (missing namespace access, plan eligibility, stale page tokens). Bare status codes hid that, so every failed-call exception now appends it. Co-Authored-By: Claude Fable 5 --- src/VCS/Adapter/Git/Origin.php | 81 +++++++++++++++++++++------------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/src/VCS/Adapter/Git/Origin.php b/src/VCS/Adapter/Git/Origin.php index 783a9f14..9bfabb6a 100644 --- a/src/VCS/Adapter/Git/Origin.php +++ b/src/VCS/Adapter/Git/Origin.php @@ -294,7 +294,7 @@ protected function getInstallation(string $installationId): array $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to get installation {$installationId}: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to get installation {$installationId}', $response); } return \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -335,7 +335,7 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri } if ($statusCode >= 400) { - throw new Exception("Failed to search repositories: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to search repositories', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -451,7 +451,7 @@ protected function installationRepositories(): \Generator $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to list installation repositories: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to list installation repositories', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -486,7 +486,7 @@ public function getRepository(string $owner, string $repositoryName): array throw new RepositoryNotFound('Repository not found.'); } if ($statusCode >= 400) { - throw new Exception("Failed to get repository {$repositoryName}: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to get repository {$repositoryName}', $response); } return $this->normalizeRepository(\is_array($response['body'] ?? null) ? $response['body'] : []); @@ -511,7 +511,7 @@ public function createRepository(string $owner, string $repositoryName, bool $pr $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Creating repository {$repositoryName} failed with status code {$statusCode}", (int) $statusCode); + throw $this->requestFailed("Creating repository {$repositoryName} failed", $response); } return $this->normalizeRepository(\is_array($response['body'] ?? null) ? $response['body'] : []); @@ -545,7 +545,7 @@ public function deleteRepository(string $owner, string $repositoryName): bool $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Deleting repository {$repositoryName} failed with status code {$statusCode}", (int) $statusCode); + throw $this->requestFailed("Deleting repository {$repositoryName} failed", $response); } return true; @@ -567,7 +567,7 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName, $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to list pull requests: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to list pull requests', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -593,7 +593,7 @@ public function getPullRequest(string $owner, string $repositoryName, int $pullR $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to get pull request: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to get pull request', $response); } return $this->normalizePullRequest(\is_array($response['body'] ?? null) ? $response['body'] : []); @@ -620,7 +620,7 @@ public function createPullRequest(string $owner, string $repositoryName, string $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to create pull request: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to create pull request', $response); } return $this->normalizePullRequest(\is_array($response['body'] ?? null) ? $response['body'] : []); @@ -680,7 +680,7 @@ public function createComment(string $owner, string $repositoryName, int $pullRe $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to create comment: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to create comment', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -726,7 +726,7 @@ public function updateComment(string $owner, string $repositoryName, string $com $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to update comment: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to update comment', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -1200,7 +1200,7 @@ public function updateCommitStatus(string $repositoryName, string $SHA, string $ $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to update commit status: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to update commit status', $response); } } @@ -1325,7 +1325,7 @@ public function createCheckRun( $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to create check run: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to create check run', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -1360,7 +1360,7 @@ protected function fetchCheckRun(string $owner, string $repositoryName, string $ $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to get check run {$checkRunId}: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to get check run {$checkRunId}', $response); } return \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -1458,7 +1458,7 @@ public function updateCheckRun( $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to update check run {$checkRunId}: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to update check run {$checkRunId}', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -1483,7 +1483,7 @@ protected function fetchCheckSuite(string $owner, string $repositoryName, string $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to get check suite {$checkSuiteId}: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to get check suite {$checkSuiteId}', $response); } return \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -1958,7 +1958,7 @@ public function updatePullRequest(string $owner, string $repositoryName, int $pu $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to update pull request: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to update pull request', $response); } return $this->normalizePullRequest(\is_array($response['body'] ?? null) ? $response['body'] : []); @@ -1981,7 +1981,7 @@ public function mergePullRequest(string $owner, string $repositoryName, int $pul $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to merge pull request: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to merge pull request', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -2069,7 +2069,7 @@ public function createPullRequestReview(string $owner, string $repositoryName, i $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to create pull request review: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to create pull request review', $response); } return \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -2105,7 +2105,7 @@ public function updatePullRequestReview(string $owner, string $repositoryName, i $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to update pull request review: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to update pull request review', $response); } return \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -2128,7 +2128,7 @@ public function dismissPullRequestReview(string $owner, string $repositoryName, $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to dismiss pull request review: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to dismiss pull request review', $response); } return \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -2210,7 +2210,7 @@ public function compareCommits(string $owner, string $repositoryName, string $ba $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to compare commits: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to compare commits', $response); } return \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -2267,7 +2267,7 @@ public function batchGetRepositoryContents(string $owner, string $repositoryName $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to batch get contents: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to batch get contents', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -2416,7 +2416,7 @@ public function batchUpsertCheckRuns(string $owner, string $repositoryName, stri $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to batch upsert check runs: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to batch upsert check runs', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -2439,7 +2439,7 @@ public function getAuthenticatedApp(): array $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to get the authenticated app: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to get the authenticated app', $response); } return \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -2470,7 +2470,7 @@ public function deleteInstallation(string $installationId): bool $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to delete installation {$installationId}: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to delete installation {$installationId}', $response); } return true; @@ -2508,7 +2508,7 @@ public function listWebhookDeliveries(?bool $delivered = null, string $eventType $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to list webhook deliveries: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to list webhook deliveries', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -2537,7 +2537,7 @@ public function redeliverWebhookDeliveries(array $deliveryIds): array $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to redeliver webhook deliveries: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to redeliver webhook deliveries', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -2564,7 +2564,7 @@ public function getRateLimit(): array $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to get the rate limit: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to get the rate limit', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -2604,7 +2604,7 @@ public function syncMirror(string $owner, string $repositoryName, string $ref, b $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to sync mirror: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to sync mirror', $response); } return $statusCode === 200; @@ -2633,7 +2633,7 @@ protected function collectPages(string $path, string $field, array $params = [], $statusCode = $response['headers']['status-code'] ?? 0; if ($statusCode >= 400) { - throw new Exception("Failed to list {$field}: HTTP {$statusCode}", (int) $statusCode); + throw $this->requestFailed('Failed to list {$field}', $response); } $responseBody = \is_array($response['body'] ?? null) ? $response['body'] : []; @@ -2644,6 +2644,25 @@ protected function collectPages(string $path, string $field, array $params = [], return $items; } + /** + * Builds the exception for a failed call, carrying Origin's own error + * message when it sent one - its Google-RPC bodies usually name the exact + * rule that refused the request. + * + * @param array $response + */ + protected function requestFailed(string $action, array $response): Exception + { + $statusCode = (int) ($response['headers']['status-code'] ?? 0); + $body = $response['body'] ?? null; + $message = \is_array($body) ? \strval($body['message'] ?? '') : ''; + + return new Exception( + "{$action}: HTTP {$statusCode}" . ($message !== '' ? " ({$message})" : ''), + $statusCode + ); + } + protected function repositoryPath(string $owner, string $repositoryName): string { return '/repos/' . \rawurlencode($owner) . '/' . \rawurlencode($repositoryName); From 41b0ea07fd78081fb9a29f16d578b23840891803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 18 Aug 2026 16:13:55 +0200 Subject: [PATCH 06/14] fix: reach Origin commits on branches whose names contain slashes /commits/{sha} takes one path segment, so a branch like feature/x read as extra segments and answered 404. Origin's gateway accepts a percent-encoded slash, so the commits endpoints (commit, files, check runs) now fully encode the ref. The git/ref path keeps literal slashes; its binding spans segments. Co-Authored-By: Claude Fable 5 --- src/VCS/Adapter/Git/Origin.php | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/VCS/Adapter/Git/Origin.php b/src/VCS/Adapter/Git/Origin.php index 9bfabb6a..5055220f 100644 --- a/src/VCS/Adapter/Git/Origin.php +++ b/src/VCS/Adapter/Git/Origin.php @@ -1691,7 +1691,7 @@ public function getCommit(string $owner, string $repositoryName, string $commitH { $response = $this->call( self::METHOD_GET, - $this->repositoryPath($owner, $repositoryName) . '/commits/' . $this->encodeRef($commitHash), + $this->repositoryPath($owner, $repositoryName) . '/commits/' . \rawurlencode($commitHash), ['Authorization' => "Bearer {$this->accessToken}"] ); @@ -2185,7 +2185,7 @@ public function listCommits(string $owner, string $repositoryName, string $sha = public function listCommitFiles(string $owner, string $repositoryName, string $sha): array { return $this->collectPages( - $this->repositoryPath($owner, $repositoryName) . '/commits/' . $this->encodeRef($sha) . '/files', + $this->repositoryPath($owner, $repositoryName) . '/commits/' . \rawurlencode($sha) . '/files', 'files' ); } @@ -2307,7 +2307,7 @@ public function getCheckSuite(string $owner, string $repositoryName, string $che public function listCheckRunsForCommit(string $owner, string $repositoryName, string $sha): array { $checkRuns = $this->collectPages( - $this->repositoryPath($owner, $repositoryName) . '/commits/' . $this->encodeRef($sha) . '/check-runs', + $this->repositoryPath($owner, $repositoryName) . '/commits/' . \rawurlencode($sha) . '/check-runs', 'checkRuns' ); @@ -2669,8 +2669,9 @@ protected function repositoryPath(string $owner, string $repositoryName): string } /** - * Encodes a ref for use inside a URL path while keeping the slashes that - * separate its segments. + * Encodes a ref for the git/ref path, whose binding spans path segments, + * keeping the slashes that separate them. The commits endpoints instead + * take one fully encoded segment (a slash rides as %2F). */ protected function encodeRef(string $ref): string { From 8b37a20f5ae04b1ab62e733da5267946d454aaa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Tue, 18 Aug 2026 17:30:44 +0200 Subject: [PATCH 07/14] feat: report whether comment images can render on the provider Origin renders comment markdown without an image proxy, so images on a consumer's own host cannot display there; consumers can now ask and fall back to text. Co-Authored-By: Claude Fable 5 --- src/VCS/Adapter/Git.php | 12 ++++++++++++ src/VCS/Adapter/Git/Origin.php | 9 +++++++++ 2 files changed, 21 insertions(+) diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index 7ec5a2cc..90704d82 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -109,6 +109,18 @@ public function supportsRepositoryArchives(): bool return true; } + /** + * Whether images embedded in pull request comments can render on the + * provider. Providers without an image proxy (the way GitHub rewrites + * comment images through its camo CDN) cannot display images hosted on + * the consumer's own host - often private or plain-HTTP - so consumers + * should fall back to text there. + */ + public function supportsCommentImages(): bool + { + return true; + } + /** * Headers a caller must send with getRepositoryPresignedUrl() to reach a * private repository. diff --git a/src/VCS/Adapter/Git/Origin.php b/src/VCS/Adapter/Git/Origin.php index 5055220f..e8f799eb 100644 --- a/src/VCS/Adapter/Git/Origin.php +++ b/src/VCS/Adapter/Git/Origin.php @@ -1728,6 +1728,15 @@ public function supportsRepositoryArchives(): bool return false; } + /** + * Origin renders comment markdown without proxying images, so images on + * a consumer's own host cannot display; comments should stay textual. + */ + public function supportsCommentImages(): bool + { + return false; + } + /** * Get latest commit of a branch * From 9eeb4a19a4bea19485749eb33a522cca7b04b96b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 19 Aug 2026 10:12:20 +0200 Subject: [PATCH 08/14] test: skip Origin suite since fixtures cannot be provisioned automatically Repository creation is denied to app installations and the partner API has no repository deletion endpoint, so the shared adapter tests can neither create nor clean up their fixture repositories. Co-Authored-By: Claude Fable 5 --- docker-compose.yml | 3 - tests/VCS/Adapter/OriginTest.php | 620 +------------------------------ 2 files changed, 16 insertions(+), 607 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index ba7f7fe0..0fb4932c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,9 +16,6 @@ services: - TESTS_GITHUB_INSTALLATION_ID - TESTS_BITBUCKET_ACCESS_TOKEN - TESTS_BITBUCKET_WORKSPACE - - TESTS_ORIGIN_PRIVATE_KEY - - TESTS_ORIGIN_APP_IDENTIFIER - - TESTS_ORIGIN_INSTALLATION_ID - TESTS_GITEA_URL=http://gitea:3000 - TESTS_REQUEST_CATCHER_URL=http://request-catcher:5000 - TESTS_FORGEJO_URL=http://forgejo:3000 diff --git a/tests/VCS/Adapter/OriginTest.php b/tests/VCS/Adapter/OriginTest.php index ebd89f95..38fa8992 100644 --- a/tests/VCS/Adapter/OriginTest.php +++ b/tests/VCS/Adapter/OriginTest.php @@ -2,612 +2,24 @@ namespace Utopia\Tests\Adapter; -use Utopia\Cache\Adapter\None; -use Utopia\Cache\Cache; -use Utopia\System\System; -use Utopia\Tests\Base; -use Utopia\VCS\Adapter\Git; -use Utopia\VCS\Adapter\Git\Origin; - -class OriginTest extends Base +use PHPUnit\Framework\TestCase; + +/** + * Placeholder for the Origin (Cursor) adapter test suite. + * + * The shared adapter tests provision a throwaway repository per test and + * delete it afterwards. Origin's partner API supports neither side of that + * lifecycle for app installations: repository creation is denied to apps + * entirely (only user principals can create repositories), and the API has + * no repository deletion endpoint. Without a way to provision or clean up + * fixture repositories, the adapter cannot be exercised automatically. + */ +class OriginTest extends TestCase { - protected static string $owner = ''; - protected static string $defaultBranch = 'main'; - /** @var array */ - protected static array $supportedWebhookScopes = [Origin::WEBHOOK_SCOPE_INSTALLATION]; - - protected static string $eventHeader = 'webhook-event-type'; - protected static string $signatureHeader = 'webhook-signature'; - - protected static string $pushEventName = 'repository.pushed'; - protected static string $pullRequestEventName = 'pull_request.created'; - - // Origin's partner API has no visibility flags, archive downloads, - // language statistics, user lookup, per-repository webhooks, or namespace - // listing. Repository deletion rides the Cursor web app's own API, and - // commit statuses ride the check run upsert. - protected static bool $reportsRepositoryVisibility = false; - protected static bool $supportsRepositoryArchives = false; - protected static bool $supportsRepositoryLanguages = false; - protected static bool $supportsUserLookup = false; - protected static bool $supportsNamespaceListing = false; - protected static bool $supportsWebhookDelivery = false; - protected static bool $resolvesOwnerFromRepositoryId = false; - - // Push deliveries carry ref updates, not per-commit file lists, and - // commits report plain git identities without linked accounts - protected static bool $reportsAffectedFilesInPushEvent = false; - protected static bool $reportsCommitAuthorAvatar = false; - protected static bool $reportsCommitAuthorUrl = false; - - protected function setupAdapter(): void + public function testOrigin(): void { - $privateKey = \str_replace('\\n', "\n", System::getEnv('TESTS_ORIGIN_PRIVATE_KEY') ?? ''); - $appId = System::getEnv('TESTS_ORIGIN_APP_IDENTIFIER') ?? ''; - static::$installationId = System::getEnv('TESTS_ORIGIN_INSTALLATION_ID') ?? ''; - - if (empty($privateKey) || empty($appId) || empty(static::$installationId)) { - $this->markTestSkipped('Origin app credentials not configured'); - } - - $adapter = new Origin(new Cache(new None())); - $adapter->initializeVariables( - installationId: static::$installationId, - privateKey: $privateKey, - appId: $appId, - accessToken: '', - refreshToken: '' + $this->markTestSkipped( + 'Origin cannot be tested automatically: the partner API does not allow app installations to create repositories, and it has no repository deletion endpoint, so fixture repositories can neither be provisioned nor cleaned up.' ); - - if (empty(static::$owner)) { - static::$owner = $adapter->getOwnerName(static::$installationId); - } - - $this->vcsAdapter = $adapter; - } - - /** - * Origin reports the repository owner as a reference carrying the slug. - * - * @param array $repository - */ - protected function ownerOf(array $repository): string - { - $this->assertArrayHasKey('owner', $repository); - $this->assertIsArray($repository['owner']); - $this->assertArrayHasKey('slug', $repository['owner']); - - return (string) $repository['owner']['slug']; - } - - /** - * Origin signs webhooks with an Ed25519 key rather than an HMAC secret; - * $secret carries the base64-encoded libsodium secret key. - */ - protected function signWebhookPayload(string $payload, string $secret): string - { - $secretKey = \base64_decode($secret, true); - if ($secretKey === false || \strlen($secretKey) !== SODIUM_CRYPTO_SIGN_SECRETKEYBYTES) { - $this->fail('The Origin webhook signer needs a base64-encoded Ed25519 secret key.'); - } - - // Origin signs the lowercase hex SHA-256 digest of ".." - $signature = \sodium_crypto_sign_detached(\hash('sha256', $payload), $secretKey); - - return 'v1ed,' . \base64_encode($signature); - } - - /** - * The generic HMAC round-trip does not apply: Origin verification takes - * the delivery's signed content and Origin's Ed25519 public key. - */ - public function testValidateWebhookEvent(): void - { - $keyPair = \sodium_crypto_sign_keypair(); - $secret = \base64_encode(\sodium_crypto_sign_secretkey($keyPair)); - $publicKey = \base64_encode(\sodium_crypto_sign_publickey($keyPair)); - - $payload = 'whd_0123456789.1755500000.{"deliveryId":"whd_0123456789"}'; - - $this->assertTrue( - $this->vcsAdapter->validateWebhookEvent($payload, $this->signWebhookPayload($payload, $secret), $publicKey) - ); - $this->assertFalse($this->vcsAdapter->validateWebhookEvent($payload, 'not-the-signature', $publicKey)); - - // A signature by a different key must not verify - $otherSecret = \base64_encode(\sodium_crypto_sign_secretkey(\sodium_crypto_sign_keypair())); - $this->assertFalse( - $this->vcsAdapter->validateWebhookEvent($payload, $this->signWebhookPayload($payload, $otherSecret), $publicKey) - ); - - // Tampered content must not verify either - $this->assertFalse( - $this->vcsAdapter->validateWebhookEvent($payload . 'tampered', $this->signWebhookPayload($payload, $secret), $publicKey) - ); - } - - public function testValidateWebhookEventAcceptsPemPublicKey(): void - { - $keyPair = \sodium_crypto_sign_keypair(); - $secret = \base64_encode(\sodium_crypto_sign_secretkey($keyPair)); - - // SPKI wrapping of a raw Ed25519 public key - $der = \hex2bin('302a300506032b6570032100') . \sodium_crypto_sign_publickey($keyPair); - $pem = "-----BEGIN PUBLIC KEY-----\n" . \chunk_split(\base64_encode((string) $der), 64, "\n") . '-----END PUBLIC KEY-----'; - - $payload = 'whd_0123456789.1755500000.{"deliveryId":"whd_0123456789"}'; - - $this->assertTrue( - $this->vcsAdapter->validateWebhookEvent($payload, $this->signWebhookPayload($payload, $secret), $pem) - ); - } - - /** - * Build a delivery the way Origin wraps a push: an envelope whose event - * payload carries the repository reference and one ref update per pushed - * ref. File lists never travel in push deliveries, so $added, $removed - * and $modified stay unused. - * - * @param array $added - * @param array $removed - * @param array $modified - */ - protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string - { - return (string) \json_encode([ - 'deliveryId' => 'whd_0123456789', - 'appId' => 'app_0123456789', - 'installationId' => 'i_0123456789', - 'event' => [ - 'id' => 'evt_0123456789', - 'type' => 'repository.pushed', - 'eventTime' => '2026-08-18T10:00:00Z', - 'payload' => [ - 'repository' => [ - 'id' => self::EVENT_REPOSITORY_ID, - 'name' => self::EVENT_REPOSITORY_NAME, - 'owner' => ['slug' => self::EVENT_OWNER, 'id' => 'ns_0123456789'], - ], - 'refUpdates' => [[ - 'ref' => 'refs/heads/' . $branch, - 'before' => $created ? \str_repeat('0', 40) : 'abc123', - 'after' => $deleted ? \str_repeat('0', 40) : self::EVENT_COMMIT_HASH, - 'created' => $created, - 'deleted' => $deleted, - 'forced' => false, - 'headCommit' => $deleted ? null : [ - 'sha' => self::EVENT_COMMIT_HASH, - 'author' => ['name' => self::EVENT_AUTHOR_NAME, 'email' => self::EVENT_AUTHOR_EMAIL], - 'committer' => ['name' => self::EVENT_AUTHOR_NAME, 'email' => self::EVENT_AUTHOR_EMAIL], - 'message' => self::EVENT_COMMIT_MESSAGE, - ], - ]], - 'refUpdatesCount' => 1, - 'pushedAt' => '2026-08-18T10:00:00Z', - 'pusher' => ['user' => ['id' => 'user_0123456789', 'email' => self::EVENT_AUTHOR_EMAIL]], - ], - ], - ]); - } - - /** - * Build a delivery the way Origin announces an opened pull request. The - * event type carries the action; there is no separate action field. - */ - protected function pullRequestPayload(bool $external = false): string - { - return (string) \json_encode([ - 'deliveryId' => 'whd_0123456789', - 'appId' => 'app_0123456789', - 'installationId' => 'i_0123456789', - 'event' => [ - 'id' => 'evt_0123456789', - 'type' => 'pull_request.created', - 'eventTime' => '2026-08-18T10:00:00Z', - 'payload' => [ - 'pullRequest' => [ - 'id' => 'pr_0123456789', - 'number' => (string) self::EVENT_PULL_REQUEST_NUMBER, - 'state' => 'open', - 'draft' => false, - 'merged' => false, - 'title' => 'Test PR', - 'body' => '', - 'head' => ['ref' => 'refs/heads/' . self::EVENT_HEAD_BRANCH, 'sha' => self::EVENT_COMMIT_HASH], - 'base' => ['ref' => 'refs/heads/' . static::$defaultBranch, 'sha' => 'abc123'], - 'author' => ['user' => ['id' => 'user_0123456789', 'email' => self::EVENT_AUTHOR_EMAIL]], - ], - 'repository' => [ - 'id' => self::EVENT_REPOSITORY_ID, - 'name' => self::EVENT_REPOSITORY_NAME, - 'owner' => ['slug' => self::EVENT_OWNER, 'id' => 'ns_0123456789'], - ], - ], - ], - ]); - } - - /** - * Origin pull requests always open from a branch of the same repository - - * there is no fork model - so no delivery can describe an external one. - */ - public function testGetEventPullRequestDetectsExternal(): void - { - $events = $this->vcsAdapter->getEvents(static::$pullRequestEventName, $this->pullRequestPayload(external: true)); - $this->assertCount(1, $events); - $this->assertFalse($events[0]['external']); - } - - public function testGetEventPullRequestMapsLifecycleActions(): void - { - $payload = \json_decode($this->pullRequestPayload(), true); - $this->assertIsArray($payload); - - $expected = [ - 'pull_request.created' => 'opened', - 'pull_request.head_ref.pushed' => 'synchronize', - 'pull_request.base_ref.updated' => 'edited', - 'pull_request.metadata.updated' => 'edited', - 'pull_request.closed' => 'closed', - 'pull_request.merged' => 'closed', - 'pull_request.reopened' => 'reopened', - 'pull_request.published' => 'ready_for_review', - ]; - - foreach ($expected as $type => $action) { - $payload['event']['type'] = $type; - $events = $this->vcsAdapter->getEvents($type, (string) \json_encode($payload)); - $this->assertCount(1, $events); - $this->assertSame($action, $events[0]['action'], "Unexpected action for {$type}"); - } - - // Comment, review and reviewer deliveries are not lifecycle events - $payload['event']['type'] = 'pull_request.comment.created'; - $this->assertSame([], $this->vcsAdapter->getEvents('pull_request.comment.created', (string) \json_encode($payload))); - } - - public function testGetEventPushWithMultipleRefUpdates(): void - { - $payload = \json_decode($this->pushPayload(static::$defaultBranch), true); - $this->assertIsArray($payload); - - $secondRef = $payload['event']['payload']['refUpdates'][0]; - $secondRef['ref'] = 'refs/heads/feature-branch'; - $payload['event']['payload']['refUpdates'][] = $secondRef; - $payload['event']['payload']['refUpdatesCount'] = 2; - - $events = $this->vcsAdapter->getEvents(static::$pushEventName, (string) \json_encode($payload)); - - $this->assertCount(2, $events); - $this->assertSame(static::$defaultBranch, $events[0]['branch']); - $this->assertSame('feature-branch', $events[1]['branch']); - } - - public function testGetEventInstallation(): void - { - $payload = (string) \json_encode([ - 'deliveryId' => 'whd_0123456789', - 'appId' => 'app_0123456789', - 'installationId' => 'i_0123456789', - 'event' => [ - 'id' => 'evt_0123456789', - 'type' => 'installation.deleted', - 'eventTime' => '2026-08-18T10:00:00Z', - 'payload' => [ - 'installation' => [ - 'id' => 'i_0123456789', - 'target' => ['slug' => 'test-workspace', 'id' => 'ns_0123456789'], - 'repoSelectionMode' => 'all', - ], - 'app' => ['id' => 'app_0123456789', 'slug' => 'test-app'], - ], - ], - ]); - - $events = $this->vcsAdapter->getEvents('installation.deleted', $payload); - $this->assertIsArray($events); - $this->assertCount(1, $events); - $result = $events[0]; - - $this->assertSame('deleted', $result['action']); - $this->assertSame('i_0123456789', $result['installationId']); - $this->assertSame('test-workspace', $result['userName']); - } - - /** - * The adapter under test, with its Origin-specific surface visible. - */ - private function origin(): Origin - { - \assert($this->vcsAdapter instanceof Origin); - - return $this->vcsAdapter; - } - - /** - * @return array{string, int} Repository name and pull request number - */ - private function createRepositoryWithPullRequest(string $prefix): array - { - $repositoryName = $prefix . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $this->getLatestCommitEventually($repositoryName); - $this->vcsAdapter->createBranch(static::$owner, $repositoryName, 'feature-branch', static::$defaultBranch); - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'feature.txt', 'feature content', 'Add feature', 'feature-branch'); - - $pullRequest = $this->vcsAdapter->createPullRequest( - static::$owner, - $repositoryName, - 'Test PR', - 'feature-branch', - static::$defaultBranch, - 'Test PR description' - ); - - return [$repositoryName, $this->pullRequestNumberOf($pullRequest)]; - } - - public function testUpdatePullRequest(): void - { - [$repositoryName, $prNumber] = $this->createRepositoryWithPullRequest('test-update-pr-'); - - try { - $updated = $this->origin()->updatePullRequest(static::$owner, $repositoryName, $prNumber, title: 'Updated title', body: 'Updated body'); - $this->assertSame('Updated title', $updated['title']); - $this->assertSame('Updated body', $updated['body']); - - $closed = $this->origin()->updatePullRequest(static::$owner, $repositoryName, $prNumber, state: 'closed'); - $this->assertSame('closed', $closed['state']); - - $reopened = $this->origin()->updatePullRequest(static::$owner, $repositoryName, $prNumber, state: 'open'); - $this->assertSame('open', $reopened['state']); - } finally { - $this->discardRepositories($repositoryName); - } - } - - public function testMergePullRequest(): void - { - [$repositoryName, $prNumber] = $this->createRepositoryWithPullRequest('test-merge-pr-'); - - try { - $result = $this->origin()->mergePullRequest(static::$owner, $repositoryName, $prNumber); - - $this->assertNotEmpty($result['mergeCommitSha']); - $this->assertContains($prNumber, $result['mergedPullNumbers']); - $this->assertTrue($result['pullRequest']['merged']); - - // The merged file has to be readable from the base branch - $this->assertEventually(function () use ($repositoryName) { - $content = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'feature.txt', static::$defaultBranch); - $this->assertSame('feature content', $content['content']); - }); - } finally { - $this->discardRepositories($repositoryName); - } - } - - public function testListPullRequests(): void - { - [$repositoryName, $prNumber] = $this->createRepositoryWithPullRequest('test-list-prs-'); - - try { - $open = $this->origin()->listPullRequests(static::$owner, $repositoryName); - $this->assertContains($prNumber, \array_column($open, 'number')); - - $this->assertSame([], $this->origin()->listPullRequests(static::$owner, $repositoryName, 'closed')); - - $byHead = $this->origin()->listPullRequests(static::$owner, $repositoryName, 'open', 'feature-branch'); - $this->assertContains($prNumber, \array_column($byHead, 'number')); - } finally { - $this->discardRepositories($repositoryName); - } - } - - public function testListPullRequestCommentsAndCommits(): void - { - [$repositoryName, $prNumber] = $this->createRepositoryWithPullRequest('test-pr-comments-commits-'); - - try { - $commentId = $this->vcsAdapter->createComment(static::$owner, $repositoryName, $prNumber, 'A listed comment'); - - $comments = $this->origin()->listPullRequestComments(static::$owner, $repositoryName, $prNumber); - $this->assertContains($commentId, \array_column($comments, 'id')); - - $commits = $this->origin()->listPullRequestCommits(static::$owner, $repositoryName, $prNumber); - $this->assertNotEmpty($commits); - $messages = \array_map(fn ($commit) => $commit['commit']['message'] ?? '', $commits); - $this->assertNotEmpty(\array_filter($messages, fn ($message) => \str_starts_with($message, 'Add feature'))); - } finally { - $this->discardRepositories($repositoryName); - } - } - - public function testPullRequestReviews(): void - { - [$repositoryName, $prNumber] = $this->createRepositoryWithPullRequest('test-pr-reviews-'); - - try { - // The app authored the pull request, and authors cannot approve - // their own change, so exercise request_changes instead - $review = $this->origin()->createPullRequestReview(static::$owner, $repositoryName, $prNumber, 'request_changes', 'Needs work'); - $this->assertNotEmpty($review['id']); - $this->assertSame('request_changes', $review['verdict']); - - $reviews = $this->origin()->listPullRequestReviews(static::$owner, $repositoryName, $prNumber); - $this->assertContains($review['id'], \array_column($reviews, 'id')); - - $updated = $this->origin()->updatePullRequestReview(static::$owner, $repositoryName, $prNumber, $review['id'], 'Needs more work'); - $this->assertSame('Needs more work', $updated['body']); - - $dismissed = $this->origin()->dismissPullRequestReview(static::$owner, $repositoryName, $prNumber, $review['id'], 'Handled offline'); - $this->assertArrayHasKey('dismissal', $dismissed); - } finally { - $this->discardRepositories($repositoryName); - } - } - - public function testListCommitsAndCommitFiles(): void - { - $repositoryName = 'test-list-commits-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test', 'First commit'); - $this->getLatestCommitEventually($repositoryName); - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'second.txt', 'second', 'Second commit'); - - $commits = []; - $this->assertEventually(function () use (&$commits, $repositoryName) { - $commits = $this->origin()->listCommits(static::$owner, $repositoryName); - $this->assertCount(2, $commits); - }); - - $this->assertStringStartsWith('Second commit', $commits[0]['commitMessage']); - $this->assertNotEmpty($commits[0]['commitHash']); - $this->assertNotEmpty($commits[0]['commitUrl']); - - $files = $this->origin()->listCommitFiles(static::$owner, $repositoryName, $commits[0]['commitHash']); - $this->assertContains('second.txt', \array_column($files, 'filename')); - } finally { - $this->discardRepositories($repositoryName); - } - } - - public function testCompareCommits(): void - { - $repositoryName = 'test-compare-commits-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test', 'First commit'); - $first = $this->getLatestCommitEventually($repositoryName)['commitHash']; - - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'second.txt', 'second', 'Second commit'); - $second = ''; - $this->assertEventually(function () use (&$second, $repositoryName, $first) { - $second = $this->vcsAdapter->getLatestCommit(static::$owner, $repositoryName, static::$defaultBranch)['commitHash']; - $this->assertNotSame($first, $second); - }); - - $comparison = $this->origin()->compareCommits(static::$owner, $repositoryName, $first, $second); - $this->assertSame('ahead', $comparison['status']); - $this->assertSame(1, $comparison['aheadBy']); - - $this->assertSame('identical', $this->origin()->compareCommits(static::$owner, $repositoryName, $first, $first)['status']); - } finally { - $this->discardRepositories($repositoryName); - } - } - - public function testGetBlob(): void - { - $repositoryName = 'test-get-blob-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Blob content'); - - $file = $this->vcsAdapter->getRepositoryContent(static::$owner, $repositoryName, 'README.md'); - $blob = $this->origin()->getBlob(static::$owner, $repositoryName, (string) $file['sha']); - - $this->assertSame('# Blob content', $blob['content']); - $this->assertSame($file['sha'], $blob['sha']); - } finally { - $this->discardRepositories($repositoryName); - } - } - - public function testBatchGetRepositoryContents(): void - { - $repositoryName = 'test-batch-get-contents-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'src/main.php', 'assertEventually(function () use (&$contents, $repositoryName) { - $contents = $this->origin()->batchGetRepositoryContents( - static::$owner, - $repositoryName, - ['README.md', 'src/main.php', 'missing.txt'] - ); - $this->assertSame('# Test', $contents['README.md'] ?? null); - }); - - $this->assertSame('assertNull($contents['missing.txt']); - } finally { - $this->discardRepositories($repositoryName); - } - } - - public function testBatchUpsertAndListCheckRuns(): void - { - $repositoryName = 'test-batch-check-runs-' . \uniqid(); - $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); - - try { - $this->vcsAdapter->createFile(static::$owner, $repositoryName, 'README.md', '# Test'); - $commitHash = $this->getLatestCommitEventually($repositoryName)['commitHash']; - - $checkRuns = $this->origin()->batchUpsertCheckRuns(static::$owner, $repositoryName, $commitHash, 'ci', [ - ['name' => 'ci/build', 'status' => 'in_progress'], - ['name' => 'ci/lint', 'conclusion' => 'success', 'title' => 'Lint passed', 'summary' => 'No issues.'], - ]); - - $this->assertCount(2, $checkRuns); - $this->assertSame('in_progress', $checkRuns[0]['status']); - $this->assertSame('success', $checkRuns[1]['conclusion']); - - $forCommit = $this->origin()->listCheckRunsForCommit(static::$owner, $repositoryName, $commitHash); - $names = \array_column($forCommit, 'name'); - $this->assertContains('ci/build', $names); - $this->assertContains('ci/lint', $names); - - $suiteId = (string) $checkRuns[0]['check_suite_id']; - $this->assertNotEmpty($suiteId); - $this->assertSame('ci', $this->origin()->getCheckSuite(static::$owner, $repositoryName, $suiteId)['key']); - - $forSuite = $this->origin()->listCheckRunsForSuite(static::$owner, $repositoryName, $suiteId); - $this->assertCount(2, $forSuite); - } finally { - $this->discardRepositories($repositoryName); - } - } - - public function testGetRateLimit(): void - { - $rateLimit = $this->origin()->getRateLimit(); - - $this->assertGreaterThan(0, $rateLimit['limit']); - $this->assertGreaterThanOrEqual(0, $rateLimit['remaining']); - $this->assertGreaterThan(0, $rateLimit['reset']); - } - - public function testGetAuthenticatedApp(): void - { - $app = $this->origin()->getAuthenticatedApp(); - - $this->assertNotEmpty($app['id']); - $this->assertNotEmpty($app['slug']); - } - - public function testListInstallations(): void - { - $installations = $this->origin()->listInstallations(); - - $this->assertContains(static::$installationId, \array_column($installations, 'id')); - } - - public function testListWebhookDeliveries(): void - { - $page = $this->origin()->listWebhookDeliveries(); - - $this->assertIsArray($page['deliveries']); - $this->assertIsString($page['nextPageToken']); } } From d0199a7acf4ded7d7de4138e8ea9830652e37ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 19 Aug 2026 10:21:38 +0200 Subject: [PATCH 09/14] feat: report whether a provider can host public repositories Origin scopes every repository to its workspace, so nothing it hosts is anonymously reachable. The new Base test proves publicness end to end: a public repository has to answer an anonymous git ref advertisement, and a private one has to refuse the same request. Co-Authored-By: Claude Fable 5 --- src/VCS/Adapter/Git.php | 11 ++++++ src/VCS/Adapter/Git/Origin.php | 9 +++++ tests/VCS/Adapter/BitbucketTest.php | 5 +++ tests/VCS/Adapter/ForgejoTest.php | 5 +++ tests/VCS/Adapter/GitHubTest.php | 5 +++ tests/VCS/Adapter/GitLabTest.php | 5 +++ tests/VCS/Adapter/GiteaTest.php | 5 +++ tests/VCS/Adapter/GogsTest.php | 5 +++ tests/VCS/Base.php | 54 +++++++++++++++++++++++++++++ 9 files changed, 104 insertions(+) diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index 90704d82..70dd6d1f 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -121,6 +121,17 @@ public function supportsCommentImages(): bool return true; } + /** + * Whether the provider can host repositories that anonymous clients are + * able to read. Providers that scope every repository to an + * authenticated audience (the way Origin binds them to their workspace) + * have no public repositories, whatever a visibility flag may claim. + */ + public function supportsPublicRepositories(): bool + { + return true; + } + /** * Headers a caller must send with getRepositoryPresignedUrl() to reach a * private repository. diff --git a/src/VCS/Adapter/Git/Origin.php b/src/VCS/Adapter/Git/Origin.php index e8f799eb..2c03e1fa 100644 --- a/src/VCS/Adapter/Git/Origin.php +++ b/src/VCS/Adapter/Git/Origin.php @@ -1737,6 +1737,15 @@ public function supportsCommentImages(): bool return false; } + /** + * Origin has no public repositories: every repository is scoped to its + * owning workspace and unreachable without credentials. + */ + public function supportsPublicRepositories(): bool + { + return false; + } + /** * Get latest commit of a branch * diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 3bb9cffb..84cd3686 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -50,6 +50,11 @@ protected function signWebhookPayload(string $payload, string $secret): string return 'sha256=' . hash_hmac('sha256', $payload, $secret); } + protected function anonymousCloneUrl(string $repositoryName): string + { + return 'https://bitbucket.org/' . $this->ownerPath() . '/' . $repositoryName . '.git'; + } + protected function setupAdapter(): void { if (empty(static::$accessToken)) { diff --git a/tests/VCS/Adapter/ForgejoTest.php b/tests/VCS/Adapter/ForgejoTest.php index 5ee0436e..1e9ce230 100644 --- a/tests/VCS/Adapter/ForgejoTest.php +++ b/tests/VCS/Adapter/ForgejoTest.php @@ -41,6 +41,11 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } + protected function anonymousCloneUrl(string $repositoryName): string + { + return System::getEnv('TESTS_FORGEJO_URL', 'http://forgejo:3000') . '/' . $this->ownerPath() . '/' . $repositoryName . '.git'; + } + protected function setupForgejo(): void { $tokenFile = '/forgejo-data/gitea/token.txt'; diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index a31f5851..a29cb45d 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -33,6 +33,11 @@ protected function signWebhookPayload(string $payload, string $secret): string protected static string $eventHeader = 'x-github-event'; protected static string $signatureHeader = 'x-hub-signature-256'; + protected function anonymousCloneUrl(string $repositoryName): string + { + return 'https://github.com/' . $this->ownerPath() . '/' . $repositoryName . '.git'; + } + protected function setupAdapter(): void { $privateKey = str_replace('\\n', "\n", System::getEnv('TESTS_GITHUB_PRIVATE_KEY') ?? ''); diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 13c4704b..62625b89 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -69,6 +69,11 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } + protected function anonymousCloneUrl(string $repositoryName): string + { + return System::getEnv('TESTS_GITLAB_URL', 'http://gitlab:80') . '/' . $this->ownerPath() . '/' . $repositoryName . '.git'; + } + /** * GitLab owners are carried as "id:path", but it reports the path alone. */ diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 91d735f1..2777cd38 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -59,6 +59,11 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } + protected function anonymousCloneUrl(string $repositoryName): string + { + return System::getEnv('TESTS_GITEA_URL', 'http://gitea:3000') . '/' . $this->ownerPath() . '/' . $repositoryName . '.git'; + } + protected function setupGitea(): void { $tokenFile = '/data/gitea/token.txt'; diff --git a/tests/VCS/Adapter/GogsTest.php b/tests/VCS/Adapter/GogsTest.php index 10c5a7d1..14bd6c09 100644 --- a/tests/VCS/Adapter/GogsTest.php +++ b/tests/VCS/Adapter/GogsTest.php @@ -48,6 +48,11 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } + protected function anonymousCloneUrl(string $repositoryName): string + { + return System::getEnv('TESTS_GOGS_URL', 'http://gogs:3000') . '/' . $this->ownerPath() . '/' . $repositoryName . '.git'; + } + protected function setupGogs(): void { $tokenFile = '/gogs-data/gogs/token.txt'; diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 2b140a6c..8b4d810d 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -222,6 +222,12 @@ abstract protected function pushPayload(string $branch, array $added = [], array */ abstract protected function pullRequestPayload(bool $external = false): string; + /** + * URL an anonymous git client would clone the repository from over HTTP, + * with no credentials embedded. + */ + abstract protected function anonymousCloneUrl(string $repositoryName): string; + protected function setUp(): void { $this->setupAdapter(); @@ -505,6 +511,54 @@ public function testCreatePrivateRepository(): void } } + /** + * Response an anonymous git client gets for the repository: the ref + * advertisement request `git clone` opens with, sent without credentials. + * + * @return array{0: int, 1: string} Status code and body + */ + private function fetchAnonymousRefAdvertisement(string $repositoryName): array + { + $client = new Client(); + $response = $client->fetch( + url: $this->anonymousCloneUrl($repositoryName) . '/info/refs', + method: 'GET', + query: ['service' => 'git-upload-pack'] + ); + + return [$response->getStatusCode(), $response->text()]; + } + + /** + * The visibility flag alone proves nothing about what reaches the + * outside; a public repository has to answer an anonymous git client. + * A private repository has to refuse the same request, or the public + * answer would say nothing beyond the server being up. + */ + public function testPublicRepositoryIsPubliclyAccessible(): void + { + $this->skipUnlessSupported($this->vcsAdapter->supportsPublicRepositories(), 'public repositories'); + + $publicRepository = 'test-public-access-' . \uniqid(); + $privateRepository = 'test-private-access-' . \uniqid(); + + $this->vcsAdapter->createRepository(static::$owner, $publicRepository, false); + $this->vcsAdapter->createRepository(static::$owner, $privateRepository, true); + + try { + $this->assertEventually(function () use ($publicRepository) { + [$status, $body] = $this->fetchAnonymousRefAdvertisement($publicRepository); + $this->assertSame(200, $status, 'An anonymous git client cannot reach the public repository'); + $this->assertStringContainsString('git-upload-pack', $body, 'The anonymous response is not a git ref advertisement'); + }); + + [$status] = $this->fetchAnonymousRefAdvertisement($privateRepository); + $this->assertNotSame(200, $status, 'An anonymous git client can read the private repository'); + } finally { + $this->discardRepositories($publicRepository, $privateRepository); + } + } + public function testGetRepository(): void { $repositoryName = 'test-get-repository-' . \uniqid(); From d4cff6f1d9fe43b6f4a09de16717ea0dc37f6cbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 19 Aug 2026 10:25:59 +0200 Subject: [PATCH 10/14] refactor: restore the shared suite's teardown to main's shape The Origin E2E suite that needed deletion-less teardown and gated visibility checks is gone, so the supportsRepositoryDeletion, reportsRepositoryVisibility and supportsRepositoryArchives test flags guarded nothing. Base.php now diverges from main only by the anonymous public-access test. Co-Authored-By: Claude Fable 5 --- tests/VCS/Base.php | 68 ++++++++++------------------------------------ 1 file changed, 14 insertions(+), 54 deletions(-) diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 8b4d810d..625d29a1 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -133,24 +133,6 @@ abstract class Base extends TestCase protected static bool $supportsNamespaceListing = true; - /** - * Whether the provider can delete a repository at all. Origin has no - * deletion endpoint, which also leaves test repositories behind for a - * manual sweep. - */ - protected static bool $supportsRepositoryDeletion = true; - - /** - * Whether repositories carry a visibility flag. Origin scopes visibility - * to the owning workspace instead of reporting one per repository. - */ - protected static bool $reportsRepositoryVisibility = true; - - /** - * Whether the provider can hand out an archive download URL. - */ - protected static bool $supportsRepositoryArchives = true; - /** * Whether a push event names the files it touched. Bitbucket's payload * carries no file lists at all. @@ -397,11 +379,6 @@ protected function getLatestCommitEventually(string $repositoryName): array */ protected function discardRepositories(string ...$repositoryNames): void { - // A provider without a deletion API leaves nothing for teardown to do - if (!static::$supportsRepositoryDeletion) { - return; - } - $failures = []; foreach ($repositoryNames as $repositoryName) { @@ -475,15 +452,11 @@ public function testCreateRepository(): void $this->assertSame($repositoryName, $result['name']); $this->assertPushedAt($result); - if (static::$reportsRepositoryVisibility) { - $this->assertFalse($this->isPrivate($result), 'createRepository() reported the new repository as private'); - } + $this->assertFalse($this->isPrivate($result), 'createRepository() reported the new repository as private'); $this->assertSame($this->ownerPath(), $this->ownerOf($result)); $fetched = $this->vcsAdapter->getRepository(static::$owner, $repositoryName); - if (static::$reportsRepositoryVisibility) { - $this->assertFalse($this->isPrivate($fetched), 'getRepository() reported the new repository as private'); - } + $this->assertFalse($this->isPrivate($fetched), 'getRepository() reported the new repository as private'); $this->assertSame($this->ownerPath(), $this->ownerOf($fetched)); } finally { $this->discardRepositories($repositoryName); @@ -492,8 +465,6 @@ public function testCreateRepository(): void public function testCreatePrivateRepository(): void { - $this->skipUnlessSupported(static::$reportsRepositoryVisibility, 'repository visibility'); - $repositoryName = 'test-create-private-' . \uniqid(); $result = $this->vcsAdapter->createRepository(static::$owner, $repositoryName, true); @@ -589,8 +560,6 @@ public function testGetRepositoryWithNonExistingOwner(): void public function testDeleteRepository(): void { - $this->skipUnlessSupported(static::$supportsRepositoryDeletion, 'deleting repositories'); - $repositoryName = 'test-delete-repository-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -600,8 +569,6 @@ public function testDeleteRepository(): void public function testDeleteRepositoryTwiceFails(): void { - $this->skipUnlessSupported(static::$supportsRepositoryDeletion, 'deleting repositories'); - $repositoryName = 'test-delete-repository-twice-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); @@ -616,8 +583,6 @@ public function testDeleteRepositoryTwiceFails(): void public function testDeleteNonExistingRepositoryFails(): void { - $this->skipUnlessSupported(static::$supportsRepositoryDeletion, 'deleting repositories'); - try { $this->vcsAdapter->deleteRepository(static::$owner, 'non-existing-repo-' . \uniqid()); $this->fail('Deleting a non existing repository should have thrown'); @@ -1672,8 +1637,6 @@ public function testValidateWebhookEvent(): void public function testGetRepositoryPresignedUrl(): void { - $this->skipUnlessSupported(static::$supportsRepositoryArchives, 'archive downloads'); - $repositoryName = 'test-presigned-url-' . \uniqid(); $this->vcsAdapter->createRepository(static::$owner, $repositoryName, false); @@ -1697,8 +1660,6 @@ public function testGetRepositoryPresignedUrl(): void public function testGetRepositoryPresignedUrlWithInvalidFormat(): void { - $this->skipUnlessSupported(static::$supportsRepositoryArchives, 'archive downloads'); - $this->expectException(Exception::class); $this->vcsAdapter->getRepositoryPresignedUrl(static::$owner, 'some-repo', static::$defaultBranch, 'invalid'); } @@ -2007,7 +1968,7 @@ public function testCreateCheckRun(): void $this->assertNotEmpty($fetched['url']); $this->assertNotEmpty($fetched['html_url']); } finally { - $this->discardRepositories($repositoryName); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } public function testCreateCheckRunWithInvalidRepository(): void @@ -2033,7 +1994,7 @@ public function testGetCheckRunWithInvalidId(): void $this->expectException(\Exception::class); $this->vcsAdapter->getCheckRun(static::$owner, $repositoryName, '999999999'); } finally { - $this->discardRepositories($repositoryName); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } public function testCreateTwoCheckRunsOnSameCommit(): void @@ -2073,7 +2034,7 @@ public function testCreateTwoCheckRunsOnSameCommit(): void $this->assertEquals('ci/build', $first['name']); $this->assertEquals('ci/build', $second['name']); } finally { - $this->discardRepositories($repositoryName); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } public function testCreateCheckRunsWithSameNameOnDifferentCommits(): void @@ -2116,7 +2077,7 @@ public function testCreateCheckRunsWithSameNameOnDifferentCommits(): void $this->assertEquals('ci/build', $first['name']); $this->assertEquals('ci/build', $second['name']); } finally { - $this->discardRepositories($repositoryName); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } public function testCreateCheckRunCompleted(): void @@ -2154,7 +2115,7 @@ public function testCreateCheckRunCompleted(): void $this->assertEquals('Build passed', $checkRun['output']['title']); $this->assertEquals('All checks passed successfully.', $checkRun['output']['summary']); } finally { - $this->discardRepositories($repositoryName); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } public function testUpdateCheckRun(): void @@ -2196,7 +2157,7 @@ public function testUpdateCheckRun(): void $this->assertEquals('completed', $updated['status']); $this->assertEquals('neutral', $updated['conclusion']); } finally { - $this->discardRepositories($repositoryName); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } public function testUpdateCheckRunWithInvalidRepository(): void @@ -2227,7 +2188,7 @@ public function testUpdateCheckRunWithInvalidId(): void conclusion: 'success', ); } finally { - $this->discardRepositories($repositoryName); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } public function testUpdateCheckRunWithMissingConclusion(): void @@ -2259,7 +2220,7 @@ public function testUpdateCheckRunWithMissingConclusion(): void status: 'completed', ); } finally { - $this->discardRepositories($repositoryName); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } @@ -2317,7 +2278,7 @@ public function testListRepositoryContentsRootSentinels(): void $this->assertEquals(array_column($empty, 'name'), array_column($dotSlash, 'name')); $this->assertEquals(array_column($empty, 'name'), array_column($repeatedDotSlash, 'name')); } finally { - $this->discardRepositories($repositoryName); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } public function testGetRepositoryContentRootSentinelPrefix(): void @@ -2335,7 +2296,7 @@ public function testGetRepositoryContentRootSentinelPrefix(): void $this->assertEquals($direct['content'], $prefixed['content']); $this->assertEquals($direct['content'], $repeatedPrefix['content']); } finally { - $this->discardRepositories($repositoryName); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } public function testListRepositoryContentsMalformedNestedPath(): void @@ -2354,7 +2315,7 @@ public function testListRepositoryContentsMalformedNestedPath(): void $this->assertEquals(array_column($clean, 'name'), array_column($embeddedDot, 'name')); $this->assertEquals(array_column($clean, 'name'), array_column($doubleSlash, 'name')); } finally { - $this->discardRepositories($repositoryName); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } @@ -2407,12 +2368,11 @@ public function testGetCommitAuthorAvatar(): void $this->assertNotEmpty($commit['commitAuthorAvatar']); $this->assertStringContainsString(static::$avatarDomain, $commit['commitAuthorAvatar']); } finally { - $this->discardRepositories($repositoryName); + $this->vcsAdapter->deleteRepository(static::$owner, $repositoryName); } } public function testGetRepositoryAfterDeleteFails(): void { - $this->skipUnlessSupported(static::$supportsRepositoryDeletion, 'deleting repositories'); $this->skipUnlessSupported(static::$deletesRepositoriesSynchronously, 'deleting a repository straight away'); $repositoryName = 'test-get-deleted-repository-' . \uniqid(); From e3f839b2c4851bd758d96f3cc1f8e8ccba26842e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 19 Aug 2026 10:33:32 +0200 Subject: [PATCH 11/14] refactor: move adapter capability reporting to its own pull request supportsRepositoryArchives(), supportsCommentImages(), supportsPublicRepositories(), the anonymous public-access test and the non-Origin README marks now land through utopia-php/vcs#134; Origin keeps only its own overrides here. Co-Authored-By: Claude Fable 5 --- README.md | 4 +-- src/VCS/Adapter/Git.php | 33 ------------------ tests/VCS/Adapter/BitbucketTest.php | 5 --- tests/VCS/Adapter/ForgejoTest.php | 5 --- tests/VCS/Adapter/GitHubTest.php | 5 --- tests/VCS/Adapter/GitLabTest.php | 5 --- tests/VCS/Adapter/GiteaTest.php | 5 --- tests/VCS/Adapter/GogsTest.php | 5 --- tests/VCS/Base.php | 54 ----------------------------- 9 files changed, 2 insertions(+), 119 deletions(-) diff --git a/README.md b/README.md index fb63d763..5989c295 100644 --- a/README.md +++ b/README.md @@ -70,8 +70,8 @@ VCS Adapters: |---------|---------| | GitHub | ✅ | | Origin (Cursor) | ✅ | -| GitLab | ✅ | -| Bitbucket | ✅ | +| GitLab | | +| Bitbucket | | | Azure DevOps | | `✅ - supported, 🛠 - work in progress` diff --git a/src/VCS/Adapter/Git.php b/src/VCS/Adapter/Git.php index 70dd6d1f..f8816925 100644 --- a/src/VCS/Adapter/Git.php +++ b/src/VCS/Adapter/Git.php @@ -99,39 +99,6 @@ abstract public function createWebhook(string $owner, string $repositoryName, st */ abstract public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array; - /** - * Whether the provider can hand out an archive download URL at all, so a - * consumer can arrange its own source packaging before calling - * getRepositoryPresignedUrl() just to catch it throwing. - */ - public function supportsRepositoryArchives(): bool - { - return true; - } - - /** - * Whether images embedded in pull request comments can render on the - * provider. Providers without an image proxy (the way GitHub rewrites - * comment images through its camo CDN) cannot display images hosted on - * the consumer's own host - often private or plain-HTTP - so consumers - * should fall back to text there. - */ - public function supportsCommentImages(): bool - { - return true; - } - - /** - * Whether the provider can host repositories that anonymous clients are - * able to read. Providers that scope every repository to an - * authenticated audience (the way Origin binds them to their workspace) - * have no public repositories, whatever a visibility flag may claim. - */ - public function supportsPublicRepositories(): bool - { - return true; - } - /** * Headers a caller must send with getRepositoryPresignedUrl() to reach a * private repository. diff --git a/tests/VCS/Adapter/BitbucketTest.php b/tests/VCS/Adapter/BitbucketTest.php index 84cd3686..3bb9cffb 100644 --- a/tests/VCS/Adapter/BitbucketTest.php +++ b/tests/VCS/Adapter/BitbucketTest.php @@ -50,11 +50,6 @@ protected function signWebhookPayload(string $payload, string $secret): string return 'sha256=' . hash_hmac('sha256', $payload, $secret); } - protected function anonymousCloneUrl(string $repositoryName): string - { - return 'https://bitbucket.org/' . $this->ownerPath() . '/' . $repositoryName . '.git'; - } - protected function setupAdapter(): void { if (empty(static::$accessToken)) { diff --git a/tests/VCS/Adapter/ForgejoTest.php b/tests/VCS/Adapter/ForgejoTest.php index 1e9ce230..5ee0436e 100644 --- a/tests/VCS/Adapter/ForgejoTest.php +++ b/tests/VCS/Adapter/ForgejoTest.php @@ -41,11 +41,6 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } - protected function anonymousCloneUrl(string $repositoryName): string - { - return System::getEnv('TESTS_FORGEJO_URL', 'http://forgejo:3000') . '/' . $this->ownerPath() . '/' . $repositoryName . '.git'; - } - protected function setupForgejo(): void { $tokenFile = '/forgejo-data/gitea/token.txt'; diff --git a/tests/VCS/Adapter/GitHubTest.php b/tests/VCS/Adapter/GitHubTest.php index a29cb45d..a31f5851 100644 --- a/tests/VCS/Adapter/GitHubTest.php +++ b/tests/VCS/Adapter/GitHubTest.php @@ -33,11 +33,6 @@ protected function signWebhookPayload(string $payload, string $secret): string protected static string $eventHeader = 'x-github-event'; protected static string $signatureHeader = 'x-hub-signature-256'; - protected function anonymousCloneUrl(string $repositoryName): string - { - return 'https://github.com/' . $this->ownerPath() . '/' . $repositoryName . '.git'; - } - protected function setupAdapter(): void { $privateKey = str_replace('\\n', "\n", System::getEnv('TESTS_GITHUB_PRIVATE_KEY') ?? ''); diff --git a/tests/VCS/Adapter/GitLabTest.php b/tests/VCS/Adapter/GitLabTest.php index 62625b89..13c4704b 100644 --- a/tests/VCS/Adapter/GitLabTest.php +++ b/tests/VCS/Adapter/GitLabTest.php @@ -69,11 +69,6 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } - protected function anonymousCloneUrl(string $repositoryName): string - { - return System::getEnv('TESTS_GITLAB_URL', 'http://gitlab:80') . '/' . $this->ownerPath() . '/' . $repositoryName . '.git'; - } - /** * GitLab owners are carried as "id:path", but it reports the path alone. */ diff --git a/tests/VCS/Adapter/GiteaTest.php b/tests/VCS/Adapter/GiteaTest.php index 2777cd38..91d735f1 100644 --- a/tests/VCS/Adapter/GiteaTest.php +++ b/tests/VCS/Adapter/GiteaTest.php @@ -59,11 +59,6 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } - protected function anonymousCloneUrl(string $repositoryName): string - { - return System::getEnv('TESTS_GITEA_URL', 'http://gitea:3000') . '/' . $this->ownerPath() . '/' . $repositoryName . '.git'; - } - protected function setupGitea(): void { $tokenFile = '/data/gitea/token.txt'; diff --git a/tests/VCS/Adapter/GogsTest.php b/tests/VCS/Adapter/GogsTest.php index 14bd6c09..10c5a7d1 100644 --- a/tests/VCS/Adapter/GogsTest.php +++ b/tests/VCS/Adapter/GogsTest.php @@ -48,11 +48,6 @@ protected function setupAdapter(): void $this->vcsAdapter = $adapter; } - protected function anonymousCloneUrl(string $repositoryName): string - { - return System::getEnv('TESTS_GOGS_URL', 'http://gogs:3000') . '/' . $this->ownerPath() . '/' . $repositoryName . '.git'; - } - protected function setupGogs(): void { $tokenFile = '/gogs-data/gogs/token.txt'; diff --git a/tests/VCS/Base.php b/tests/VCS/Base.php index 625d29a1..ab571542 100644 --- a/tests/VCS/Base.php +++ b/tests/VCS/Base.php @@ -204,12 +204,6 @@ abstract protected function pushPayload(string $branch, array $added = [], array */ abstract protected function pullRequestPayload(bool $external = false): string; - /** - * URL an anonymous git client would clone the repository from over HTTP, - * with no credentials embedded. - */ - abstract protected function anonymousCloneUrl(string $repositoryName): string; - protected function setUp(): void { $this->setupAdapter(); @@ -482,54 +476,6 @@ public function testCreatePrivateRepository(): void } } - /** - * Response an anonymous git client gets for the repository: the ref - * advertisement request `git clone` opens with, sent without credentials. - * - * @return array{0: int, 1: string} Status code and body - */ - private function fetchAnonymousRefAdvertisement(string $repositoryName): array - { - $client = new Client(); - $response = $client->fetch( - url: $this->anonymousCloneUrl($repositoryName) . '/info/refs', - method: 'GET', - query: ['service' => 'git-upload-pack'] - ); - - return [$response->getStatusCode(), $response->text()]; - } - - /** - * The visibility flag alone proves nothing about what reaches the - * outside; a public repository has to answer an anonymous git client. - * A private repository has to refuse the same request, or the public - * answer would say nothing beyond the server being up. - */ - public function testPublicRepositoryIsPubliclyAccessible(): void - { - $this->skipUnlessSupported($this->vcsAdapter->supportsPublicRepositories(), 'public repositories'); - - $publicRepository = 'test-public-access-' . \uniqid(); - $privateRepository = 'test-private-access-' . \uniqid(); - - $this->vcsAdapter->createRepository(static::$owner, $publicRepository, false); - $this->vcsAdapter->createRepository(static::$owner, $privateRepository, true); - - try { - $this->assertEventually(function () use ($publicRepository) { - [$status, $body] = $this->fetchAnonymousRefAdvertisement($publicRepository); - $this->assertSame(200, $status, 'An anonymous git client cannot reach the public repository'); - $this->assertStringContainsString('git-upload-pack', $body, 'The anonymous response is not a git ref advertisement'); - }); - - [$status] = $this->fetchAnonymousRefAdvertisement($privateRepository); - $this->assertNotSame(200, $status, 'An anonymous git client can read the private repository'); - } finally { - $this->discardRepositories($publicRepository, $privateRepository); - } - } - public function testGetRepository(): void { $repositoryName = 'test-get-repository-' . \uniqid(); From ae8244a03b3680eafbeb34f1d04314028960c209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 19 Aug 2026 11:38:09 +0200 Subject: [PATCH 12/14] fix: confine created files to the checkout and page repositories to the end A caller-supplied path could climb out of the temporary checkout through ../ segments and write onto the host filesystem before git add ever saw it; paths are now normalized lexically and rejected once they escape. Repository listing also stopped after a thousand entries even with a next-page token in hand, hiding later repositories from lookups; it now follows the token until the provider stops advancing it. Co-Authored-By: Claude Fable 5 --- src/VCS/Adapter/Git/Origin.php | 47 ++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/src/VCS/Adapter/Git/Origin.php b/src/VCS/Adapter/Git/Origin.php index 2c03e1fa..f37fb528 100644 --- a/src/VCS/Adapter/Git/Origin.php +++ b/src/VCS/Adapter/Git/Origin.php @@ -438,8 +438,6 @@ public function getRepositoryName(string $repositoryId): string protected function installationRepositories(): \Generator { $pageToken = ''; - $fetched = 0; - $maxRepositories = 1000; do { $params = ['pageSize' => 100]; @@ -463,9 +461,13 @@ protected function installationRepositories(): \Generator } } - $fetched += \count($repositories); - $pageToken = \strval($responseBody['nextPageToken'] ?? ''); - } while ($pageToken !== '' && $fetched < $maxRepositories); + // A next-page token that fails to advance would page forever + $nextPageToken = \strval($responseBody['nextPageToken'] ?? ''); + if ($nextPageToken === $pageToken) { + return; + } + $pageToken = $nextPageToken; + } while ($pageToken !== ''); } /** @@ -1760,6 +1762,34 @@ public function getLatestCommit(string $owner, string $repositoryName, string $b } } + /** + * The repository path a caller-supplied file path names, normalized + * lexically. Rejects a path that climbs out of the checkout through + * `..` segments, which would otherwise write onto the host filesystem. + */ + protected function confinedPath(string $filepath): string + { + $segments = []; + foreach (\explode('/', \str_replace('\\', '/', $filepath)) as $segment) { + if ($segment === '' || $segment === '.') { + continue; + } + if ($segment === '..') { + if (\array_pop($segments) === null) { + throw new Exception("File path escapes the repository: {$filepath}"); + } + continue; + } + $segments[] = $segment; + } + + if ($segments === []) { + throw new Exception("File path names no file: {$filepath}"); + } + + return \implode('/', $segments); + } + /** * Create a file in a repository * @@ -1776,6 +1806,7 @@ public function createFile(string $owner, string $repositoryName, string $filepa $defaultBranch = \strval($repository['defaultBranch'] ?? 'main'); $targetBranch = !empty($branch) ? $branch : $defaultBranch; + $relative = $this->confinedPath($filepath); $remote = \escapeshellarg($this->authenticatedCloneUrl($owner, $repositoryName)); $directory = $this->temporaryDirectory(); $git = 'git -C ' . \escapeshellarg($directory); @@ -1792,7 +1823,7 @@ public function createFile(string $owner, string $repositoryName, string $filepa $this->execute("{$git} checkout -q FETCH_HEAD", 'Checking out the branch tip'); } - $absolute = $directory . '/' . \ltrim($filepath, '/'); + $absolute = $directory . '/' . $relative; $parent = \dirname($absolute); if (!\is_dir($parent) && !\mkdir($parent, 0777, true)) { throw new Exception("Failed to create directory for {$filepath}"); @@ -1801,13 +1832,13 @@ public function createFile(string $owner, string $repositoryName, string $filepa throw new Exception("Failed to write {$filepath}"); } - $this->execute("{$git} add " . \escapeshellarg($filepath), 'Staging the file'); + $this->execute("{$git} add " . \escapeshellarg($relative), 'Staging the file'); $this->execute("{$git} -c user.name='Utopia VCS' -c user.email='vcs@utopia.dev' commit -q -m " . \escapeshellarg($message), 'Committing the file'); $commitHash = \implode('', $this->execute("{$git} rev-parse HEAD", 'Reading the commit hash')); $this->execute("{$git} push -q {$remote} " . \escapeshellarg('HEAD:refs/heads/' . $targetBranch), 'Pushing the commit'); return [ - 'path' => $filepath, + 'path' => $relative, 'branch' => $targetBranch, 'commitHash' => $commitHash, ]; From d9c7b1335b8a974f06776fcbe906132ba36e9e4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 19 Aug 2026 11:38:25 +0200 Subject: [PATCH 13/14] test: restore Origin's credential-free coverage Webhook signature verification and delivery parsing never touch the network, so they run in CI again: Ed25519 validation with rotation and PEM keys, push and pull request normalization, lifecycle action mapping, and installation events. Only the live half of the shared suite stays skipped, since fixture repositories can neither be created nor deleted through the partner API. Co-Authored-By: Claude Fable 5 --- tests/VCS/Adapter/OriginTest.php | 311 ++++++++++++++++++++++++++++++- 1 file changed, 301 insertions(+), 10 deletions(-) diff --git a/tests/VCS/Adapter/OriginTest.php b/tests/VCS/Adapter/OriginTest.php index 38fa8992..2fc22ead 100644 --- a/tests/VCS/Adapter/OriginTest.php +++ b/tests/VCS/Adapter/OriginTest.php @@ -3,23 +3,314 @@ namespace Utopia\Tests\Adapter; use PHPUnit\Framework\TestCase; +use Utopia\Cache\Adapter\None; +use Utopia\Cache\Cache; +use Utopia\VCS\Adapter\Git\Origin; /** - * Placeholder for the Origin (Cursor) adapter test suite. - * - * The shared adapter tests provision a throwaway repository per test and - * delete it afterwards. Origin's partner API supports neither side of that - * lifecycle for app installations: repository creation is denied to apps - * entirely (only user principals can create repositories), and the API has - * no repository deletion endpoint. Without a way to provision or clean up - * fixture repositories, the adapter cannot be exercised automatically. + * Exercises everything Origin can prove without credentials: webhook + * signature verification and delivery parsing, which never touch the + * network. The live half of the shared adapter suite cannot run at all - + * see testLiveAdapterSuite(). */ class OriginTest extends TestCase { - public function testOrigin(): void + protected const REPOSITORY_ID = 'repo_0123456789'; + protected const REPOSITORY_NAME = 'test-repo'; + protected const OWNER = 'test-owner'; + protected const COMMIT_HASH = 'def4567890def4567890def4567890def4567890'; + protected const COMMIT_MESSAGE = 'Test commit message'; + protected const AUTHOR_NAME = 'Test Author'; + protected const AUTHOR_EMAIL = 'author@example.com'; + protected const HEAD_BRANCH = 'feature-branch'; + protected const PULL_REQUEST_NUMBER = 42; + + protected Origin $adapter; + + /** + * Verification and parsing need no installation, so the adapter stays + * uninitialized - initializeVariables() would reach for the network. + */ + protected function setUp(): void + { + $this->adapter = new Origin(new Cache(new None())); + } + + /** + * Sign a payload the way Origin signs its deliveries: an Ed25519 + * signature over the lowercase hex SHA-256 digest of + * "..". $secretKey is a libsodium secret key. + * + * @param non-empty-string $secretKey + */ + protected function signWebhookPayload(string $payload, string $secretKey): string + { + return 'v1ed,' . \base64_encode(\sodium_crypto_sign_detached(\hash('sha256', $payload), $secretKey)); + } + + public function testValidateWebhookEvent(): void + { + $keyPair = \sodium_crypto_sign_keypair(); + $secretKey = \sodium_crypto_sign_secretkey($keyPair); + $publicKey = \base64_encode(\sodium_crypto_sign_publickey($keyPair)); + + $payload = 'whd_0123456789.1755500000.{"deliveryId":"whd_0123456789"}'; + $signature = $this->signWebhookPayload($payload, $secretKey); + + $this->assertTrue($this->adapter->validateWebhookEvent($payload, $signature, $publicKey)); + $this->assertFalse($this->adapter->validateWebhookEvent($payload, 'not-the-signature', $publicKey)); + + // A signature by a different key must not verify + $otherSecretKey = \sodium_crypto_sign_secretkey(\sodium_crypto_sign_keypair()); + $this->assertFalse( + $this->adapter->validateWebhookEvent($payload, $this->signWebhookPayload($payload, $otherSecretKey), $publicKey) + ); + + // Tampered content must not verify either + $this->assertFalse($this->adapter->validateWebhookEvent($payload . 'tampered', $signature, $publicKey)); + + // During key rotation the header carries several space-separated + // signatures, and any one of them verifying is enough + $this->assertTrue($this->adapter->validateWebhookEvent( + $payload, + $this->signWebhookPayload($payload, $otherSecretKey) . ' ' . $signature, + $publicKey + )); + } + + public function testValidateWebhookEventAcceptsPemPublicKey(): void + { + $keyPair = \sodium_crypto_sign_keypair(); + + // SPKI wrapping of a raw Ed25519 public key + $der = \hex2bin('302a300506032b6570032100') . \sodium_crypto_sign_publickey($keyPair); + $pem = "-----BEGIN PUBLIC KEY-----\n" . \chunk_split(\base64_encode((string) $der), 64, "\n") . '-----END PUBLIC KEY-----'; + + $payload = 'whd_0123456789.1755500000.{"deliveryId":"whd_0123456789"}'; + $signature = $this->signWebhookPayload($payload, \sodium_crypto_sign_secretkey($keyPair)); + + $this->assertTrue($this->adapter->validateWebhookEvent($payload, $signature, $pem)); + } + + /** + * Build a delivery the way Origin wraps a push: an envelope whose event + * payload carries the repository reference and one ref update per + * pushed ref. + * + * @return array + */ + protected function pushPayload(string $branch, bool $created = false, bool $deleted = false): array + { + return [ + 'deliveryId' => 'whd_0123456789', + 'appId' => 'app_0123456789', + 'installationId' => 'i_0123456789', + 'event' => [ + 'id' => 'evt_0123456789', + 'type' => 'repository.pushed', + 'eventTime' => '2026-08-18T10:00:00Z', + 'payload' => [ + 'repository' => [ + 'id' => self::REPOSITORY_ID, + 'name' => self::REPOSITORY_NAME, + 'owner' => ['slug' => self::OWNER, 'id' => 'ns_0123456789'], + ], + 'refUpdates' => [[ + 'ref' => 'refs/heads/' . $branch, + 'before' => $created ? \str_repeat('0', 40) : 'abc123', + 'after' => $deleted ? \str_repeat('0', 40) : self::COMMIT_HASH, + 'created' => $created, + 'deleted' => $deleted, + 'forced' => false, + 'headCommit' => $deleted ? null : [ + 'sha' => self::COMMIT_HASH, + 'author' => ['name' => self::AUTHOR_NAME, 'email' => self::AUTHOR_EMAIL], + 'committer' => ['name' => self::AUTHOR_NAME, 'email' => self::AUTHOR_EMAIL], + 'message' => self::COMMIT_MESSAGE, + ], + ]], + 'refUpdatesCount' => 1, + 'pushedAt' => '2026-08-18T10:00:00Z', + 'pusher' => ['user' => ['id' => 'user_0123456789', 'email' => self::AUTHOR_EMAIL]], + ], + ], + ]; + } + + /** + * Build a delivery the way Origin announces a pull request event. The + * event type carries the action; there is no separate action field. + * + * @return array + */ + protected function pullRequestPayload(string $type = 'pull_request.created'): array + { + return [ + 'deliveryId' => 'whd_0123456789', + 'appId' => 'app_0123456789', + 'installationId' => 'i_0123456789', + 'event' => [ + 'id' => 'evt_0123456789', + 'type' => $type, + 'eventTime' => '2026-08-18T10:00:00Z', + 'payload' => [ + 'pullRequest' => [ + 'id' => 'pr_0123456789', + 'number' => (string) self::PULL_REQUEST_NUMBER, + 'state' => 'open', + 'draft' => false, + 'merged' => false, + 'title' => 'Test PR', + 'body' => '', + 'head' => ['ref' => 'refs/heads/' . self::HEAD_BRANCH, 'sha' => self::COMMIT_HASH], + 'base' => ['ref' => 'refs/heads/main', 'sha' => 'abc123'], + 'author' => ['user' => ['id' => 'user_0123456789', 'email' => self::AUTHOR_EMAIL]], + ], + 'repository' => [ + 'id' => self::REPOSITORY_ID, + 'name' => self::REPOSITORY_NAME, + 'owner' => ['slug' => self::OWNER, 'id' => 'ns_0123456789'], + ], + ], + ], + ]; + } + + public function testGetEventPush(): void + { + $events = $this->adapter->getEvents('repository.pushed', (string) \json_encode($this->pushPayload('main'))); + + $this->assertCount(1, $events); + $event = $events[0]; + + $this->assertSame('main', $event['branch']); + $this->assertSame(self::REPOSITORY_ID, $event['repositoryId']); + $this->assertSame(self::REPOSITORY_NAME, $event['repositoryName']); + $this->assertSame(self::OWNER, $event['owner']); + $this->assertSame('i_0123456789', $event['installationId']); + $this->assertSame(self::COMMIT_HASH, $event['commitHash']); + $this->assertSame(self::COMMIT_MESSAGE, $event['headCommitMessage']); + $this->assertSame(self::AUTHOR_NAME, $event['headCommitAuthorName']); + $this->assertSame(self::AUTHOR_EMAIL, $event['headCommitAuthorEmail']); + $this->assertFalse($event['branchCreated']); + $this->assertFalse($event['branchDeleted']); + $this->assertFalse($event['external']); + // Origin push deliveries carry no per-commit file lists + $this->assertSame([], $event['affectedFiles']); + $this->assertStringContainsString(self::REPOSITORY_NAME, \strval($event['repositoryUrl'])); + $this->assertNotEmpty($event['branchUrl']); + $this->assertNotEmpty($event['headCommitUrl']); + } + + public function testGetEventPushBranchLifecycle(): void + { + $created = $this->adapter->getEvents('repository.pushed', (string) \json_encode($this->pushPayload('new-branch', created: true))); + $this->assertTrue($created[0]['branchCreated']); + $this->assertFalse($created[0]['branchDeleted']); + + $deleted = $this->adapter->getEvents('repository.pushed', (string) \json_encode($this->pushPayload('old-branch', deleted: true))); + $this->assertFalse($deleted[0]['branchCreated']); + $this->assertTrue($deleted[0]['branchDeleted']); + } + + public function testGetEventPushWithMultipleRefUpdates(): void + { + $payload = $this->pushPayload('main'); + + $secondRef = $payload['event']['payload']['refUpdates'][0]; + $secondRef['ref'] = 'refs/heads/feature-branch'; + $payload['event']['payload']['refUpdates'][] = $secondRef; + $payload['event']['payload']['refUpdatesCount'] = 2; + + $events = $this->adapter->getEvents('repository.pushed', (string) \json_encode($payload)); + + $this->assertCount(2, $events); + $this->assertSame('main', $events[0]['branch']); + $this->assertSame('feature-branch', $events[1]['branch']); + } + + public function testGetEventPullRequest(): void + { + $events = $this->adapter->getEvents('pull_request.created', (string) \json_encode($this->pullRequestPayload())); + + $this->assertCount(1, $events); + $event = $events[0]; + + $this->assertSame('opened', $event['action']); + $this->assertSame(self::PULL_REQUEST_NUMBER, $event['pullRequestNumber']); + $this->assertSame(self::HEAD_BRANCH, $event['branch']); + $this->assertSame(self::REPOSITORY_ID, $event['repositoryId']); + $this->assertSame(self::OWNER, $event['owner']); + $this->assertSame(self::COMMIT_HASH, $event['commitHash']); + + // Origin pull requests always open from a branch of the same + // repository - there is no fork model - so none is ever external + $this->assertFalse($event['external']); + } + + public function testGetEventPullRequestMapsLifecycleActions(): void + { + $expected = [ + 'pull_request.created' => 'opened', + 'pull_request.head_ref.pushed' => 'synchronize', + 'pull_request.base_ref.updated' => 'edited', + 'pull_request.metadata.updated' => 'edited', + 'pull_request.closed' => 'closed', + 'pull_request.merged' => 'closed', + 'pull_request.reopened' => 'reopened', + 'pull_request.published' => 'ready_for_review', + ]; + + foreach ($expected as $type => $action) { + $events = $this->adapter->getEvents($type, (string) \json_encode($this->pullRequestPayload($type))); + $this->assertCount(1, $events); + $this->assertSame($action, $events[0]['action'], "Unexpected action for {$type}"); + } + + // Comment, review and reviewer deliveries are not lifecycle events + $type = 'pull_request.comment.created'; + $this->assertSame([], $this->adapter->getEvents($type, (string) \json_encode($this->pullRequestPayload($type)))); + } + + public function testGetEventInstallation(): void + { + $payload = (string) \json_encode([ + 'deliveryId' => 'whd_0123456789', + 'appId' => 'app_0123456789', + 'installationId' => 'i_0123456789', + 'event' => [ + 'id' => 'evt_0123456789', + 'type' => 'installation.deleted', + 'eventTime' => '2026-08-18T10:00:00Z', + 'payload' => [ + 'installation' => [ + 'id' => 'i_0123456789', + 'target' => ['slug' => 'test-workspace', 'id' => 'ns_0123456789'], + 'repoSelectionMode' => 'all', + ], + 'app' => ['id' => 'app_0123456789', 'slug' => 'test-app'], + ], + ], + ]); + + $events = $this->adapter->getEvents('installation.deleted', $payload); + + $this->assertCount(1, $events); + $this->assertSame('deleted', $events[0]['action']); + $this->assertSame('i_0123456789', $events[0]['installationId']); + $this->assertSame('test-workspace', $events[0]['userName']); + } + + public function testGetEventsRejectsInvalidPayload(): void + { + $this->expectException(\Exception::class); + $this->adapter->getEvents('repository.pushed', 'not json'); + } + + public function testLiveAdapterSuite(): void { $this->markTestSkipped( - 'Origin cannot be tested automatically: the partner API does not allow app installations to create repositories, and it has no repository deletion endpoint, so fixture repositories can neither be provisioned nor cleaned up.' + 'Origin cannot run the shared live suite: the partner API does not allow app installations to create repositories, and it has no repository deletion endpoint, so fixture repositories can neither be provisioned nor cleaned up.' ); } } From 2ec8d1ba84968d818fcf20bf2ebd4030c60ebd0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matej=20Ba=C4=8Do?= Date: Wed, 19 Aug 2026 11:47:27 +0200 Subject: [PATCH 14/14] fix: refuse symlinked write targets and drop repository lifecycle calls Lexical confinement was not enough once the checkout itself carried symlinks - a linked directory or file would carry the write outside the temporary directory - so createFile() now resolves the parent against the checkout and refuses link targets, validating the path before any network call. createRepository() and deleteRepository() no longer pretend: creation is denied to app installations wholesale and the partner API has no deletion endpoint, so both report themselves unsupported, matching the new capability methods. Co-Authored-By: Claude Fable 5 --- src/VCS/Adapter/Git/Origin.php | 85 ++++++++++++++------------------ tests/VCS/Adapter/OriginTest.php | 14 ++++++ 2 files changed, 51 insertions(+), 48 deletions(-) diff --git a/src/VCS/Adapter/Git/Origin.php b/src/VCS/Adapter/Git/Origin.php index f37fb528..96646055 100644 --- a/src/VCS/Adapter/Git/Origin.php +++ b/src/VCS/Adapter/Git/Origin.php @@ -57,12 +57,6 @@ class Origin extends Git protected string $endpoint = 'https://api.cursor.com/v1/origin'; - /** - * The Cursor web app's own API. Repository deletion is not part of the - * documented partner API but is available here. - */ - protected string $webApiEndpoint = 'https://cursor.com/api/origin'; - /** * Browser-facing host. origin.cursor.com redirects here. */ @@ -497,60 +491,25 @@ public function getRepository(string $owner, string $repositoryName): array /** * Create new repository * - * Origin has no repository visibility flag - who can see a repository - * follows from the owning user or team workspace - so $private is ignored. + * Origin reserves repository creation for user principals; app + * installations are denied whatever their scopes. * * @return array Details of new repository */ public function createRepository(string $owner, string $repositoryName, bool $private): array { - $response = $this->call( - self::METHOD_POST, - '/repos/' . \rawurlencode($owner), - ['Authorization' => "Bearer {$this->accessToken}"], - ['name' => $repositoryName] - ); - - $statusCode = $response['headers']['status-code'] ?? 0; - if ($statusCode >= 400) { - throw $this->requestFailed("Creating repository {$repositoryName} failed", $response); - } - - return $this->normalizeRepository(\is_array($response['body'] ?? null) ? $response['body'] : []); + throw new Exception('createRepository() is not supported by ' . $this->getName()); } /** * Delete repository * - * The documented partner API has no deletion endpoint; this rides the - * Cursor web app's own API instead, which names the owner `org`. + * The partner API has no deletion endpoint; repositories are removed + * through the Cursor UI. */ public function deleteRepository(string $owner, string $repositoryName): bool { - $endpoint = $this->endpoint; - $this->endpoint = $this->webApiEndpoint; - - try { - $response = $this->call( - self::METHOD_POST, - '/delete-repo', - [ - 'Authorization' => "Bearer {$this->accessToken}", - // The web API refuses cross-origin state changes outright - 'origin' => 'https://cursor.com', - ], - ['identifier' => ['org' => $owner, 'name' => $repositoryName]] - ); - } finally { - $this->endpoint = $endpoint; - } - - $statusCode = $response['headers']['status-code'] ?? 0; - if ($statusCode >= 400) { - throw $this->requestFailed("Deleting repository {$repositoryName} failed", $response); - } - - return true; + throw new Exception('deleteRepository() is not supported by ' . $this->getName()); } /** @@ -1721,6 +1680,23 @@ public function getCommit(string $owner, string $repositoryName, string $commitH ]; } + /** + * Origin reserves repository creation for user principals; app + * installations are denied whatever their scopes. + */ + public function supportsRepositoryCreation(): bool + { + return false; + } + + /** + * The partner API has no repository deletion endpoint. + */ + public function supportsRepositoryDeletion(): bool + { + return false; + } + /** * Origin offers no archive downloads; consumers package sources * themselves, over Git HTTPS. @@ -1801,12 +1777,13 @@ protected function confinedPath(string $filepath): string */ public function createFile(string $owner, string $repositoryName, string $filepath, string $content, string $message = 'Add file', string $branch = ''): array { + $relative = $this->confinedPath($filepath); + // Also resolves the default branch and confirms the repository exists $repository = $this->getRepository($owner, $repositoryName); $defaultBranch = \strval($repository['defaultBranch'] ?? 'main'); $targetBranch = !empty($branch) ? $branch : $defaultBranch; - $relative = $this->confinedPath($filepath); $remote = \escapeshellarg($this->authenticatedCloneUrl($owner, $repositoryName)); $directory = $this->temporaryDirectory(); $git = 'git -C ' . \escapeshellarg($directory); @@ -1828,6 +1805,18 @@ public function createFile(string $owner, string $repositoryName, string $filepa if (!\is_dir($parent) && !\mkdir($parent, 0777, true)) { throw new Exception("Failed to create directory for {$filepath}"); } + + // Lexical confinement is not enough once the checkout itself + // carries symlinks: a linked directory or file would carry the + // write outside, so resolve the parent and refuse link targets + $checkout = \realpath($directory); + $resolvedParent = \realpath($parent); + if ($checkout === false || $resolvedParent === false + || !\str_starts_with($resolvedParent . '/', $checkout . '/') + || \is_link($absolute)) { + throw new Exception("File path escapes the repository: {$filepath}"); + } + if (\file_put_contents($absolute, $content) === false) { throw new Exception("Failed to write {$filepath}"); } diff --git a/tests/VCS/Adapter/OriginTest.php b/tests/VCS/Adapter/OriginTest.php index 2fc22ead..8e271614 100644 --- a/tests/VCS/Adapter/OriginTest.php +++ b/tests/VCS/Adapter/OriginTest.php @@ -307,6 +307,20 @@ public function testGetEventsRejectsInvalidPayload(): void $this->adapter->getEvents('repository.pushed', 'not json'); } + public function testCreateFileRejectsEscapingPath(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('escapes the repository'); + $this->adapter->createFile('owner', 'repo', '../escape.txt', 'content'); + } + + public function testCreateFileRejectsEmptyPath(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('names no file'); + $this->adapter->createFile('owner', 'repo', './.', 'content'); + } + public function testLiveAdapterSuite(): void { $this->markTestSkipped(