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.'
+ );
+ }
+}