diff --git a/README.md b/README.md index 0e2908a8..fb63d763 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,7 @@ VCS Adapters: | Adapter | Status | |---------|---------| | GitHub | ✅ | +| Origin (Cursor) | ✅ | | GitLab | ✅ | | Bitbucket | ✅ | | Azure DevOps | | 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..96646055 --- /dev/null +++ b/src/VCS/Adapter/Git/Origin.php @@ -0,0 +1,2718 @@ + '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'; + + /** + * 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 $this->requestFailed('Failed to get installation {$installationId}', $response); + } + + 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 $this->requestFailed('Failed to search repositories', $response); + } + + $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_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. + * + * @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[] = $this->normalizeRepository($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 $this->normalizeRepository($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 = ''; + + 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 $this->requestFailed('Failed to list installation repositories', $response); + } + + $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; + } + } + + // A next-page token that fails to advance would page forever + $nextPageToken = \strval($responseBody['nextPageToken'] ?? ''); + if ($nextPageToken === $pageToken) { + return; + } + $pageToken = $nextPageToken; + } while ($pageToken !== ''); + } + + /** + * 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 $this->requestFailed('Failed to get repository {$repositoryName}', $response); + } + + return $this->normalizeRepository(\is_array($response['body'] ?? null) ? $response['body'] : []); + } + + /** + * Create new repository + * + * 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 + { + throw new Exception('createRepository() is not supported by ' . $this->getName()); + } + + /** + * Delete repository + * + * The partner API has no deletion endpoint; repositories are removed + * through the Cursor UI. + */ + public function deleteRepository(string $owner, string $repositoryName): bool + { + throw new Exception('deleteRepository() is not supported by ' . $this->getName()); + } + + /** + * 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 $this->requestFailed('Failed to list pull requests', $response); + } + + $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 $this->requestFailed('Failed to get pull request', $response); + } + + 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 $this->requestFailed('Failed to create pull request', $response); + } + + 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 $this->requestFailed('Failed to create comment', $response); + } + + $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 $this->requestFailed('Failed to update comment', $response); + } + + $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 + * state can be one of: error, failure, pending, success + * + * 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 + { + $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 $this->requestFailed('Failed to update commit status', $response); + } + } + + /** + * 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 + { + $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; + } + + /** + * 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 $this->requestFailed('Failed to create check run', $response); + } + + $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 $this->requestFailed('Failed to get check run {$checkRunId}', $response); + } + + 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 $this->requestFailed('Failed to update check run {$checkRunId}', $response); + } + + $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 $this->requestFailed('Failed to get check suite {$checkSuiteId}', $response); + } + + 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, + 'details_url' => \strval($checkRun['detailsUrl'] ?? ''), + '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/' . \rawurlencode($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) : '', + ]; + } + + /** + * 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. + */ + 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; + } + + /** + * 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 + * + * @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}"); + } + } + + /** + * 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 + * + * 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 + { + $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; + + $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 . '/' . $relative; + $parent = \dirname($absolute); + 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}"); + } + + $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' => $relative, + '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 $this->requestFailed('Failed to update pull request', $response); + } + + 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 $this->requestFailed('Failed to merge pull request', $response); + } + + $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 $this->requestFailed('Failed to create pull request review', $response); + } + + 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 $this->requestFailed('Failed to update pull request review', $response); + } + + 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 $this->requestFailed('Failed to dismiss pull request review', $response); + } + + 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/' . \rawurlencode($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 $this->requestFailed('Failed to compare commits', $response); + } + + 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 $this->requestFailed('Failed to batch get contents', $response); + } + + $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/' . \rawurlencode($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 $this->requestFailed('Failed to batch upsert check runs', $response); + } + + $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 $this->requestFailed('Failed to get the authenticated app', $response); + } + + 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 $this->requestFailed('Failed to delete installation {$installationId}', $response); + } + + 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 $this->requestFailed('Failed to list webhook deliveries', $response); + } + + $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 $this->requestFailed('Failed to redeliver webhook deliveries', $response); + } + + $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 $this->requestFailed('Failed to get the rate limit', $response); + } + + $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 $this->requestFailed('Failed to sync mirror', $response); + } + + 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 $this->requestFailed('Failed to list {$field}', $response); + } + + $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; + } + + /** + * 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); + } + + /** + * 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 + { + 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..8e271614 --- /dev/null +++ b/tests/VCS/Adapter/OriginTest.php @@ -0,0 +1,330 @@ +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 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( + '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.' + ); + } +}