Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion docs/add-new-vcs-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,13 @@ rather than `getEvent()` — the latter reports only the first event of a batch.

### Testing with Docker 🛠️

The existing test suite is helpful when developing a new VCS adapter. Use official Docker images from trusted sources. Add new tests for your new VCS adapter in `tests/VCS/Adapter/VCSTest.php` test class. The specific `docker-compose` command for testing can be found in the [README](/README.md#tests).
Every adapter runs the same suite. `tests/VCS/Base.php` holds the tests, and each adapter's class under `tests/VCS/Adapter/` declares how its provider differs. To test a new adapter:

1. Extend `Utopia\Tests\Base` in `tests/VCS/Adapter/NewGitAdapterTest.php` and implement its hooks: `setupAdapter()` builds the adapter against the provider, `signWebhookPayload()` signs a payload the way the provider does, and `pushPayload()` and `pullRequestPayload()` build webhook payloads shaped the way the provider sends them.
2. Declare the parts of the contract the provider lacks by overriding the capability flags, such as `$supportsTags` or `$supportsCheckRuns`. The first shared test for each capability then asserts that the adapter refuses with `X() is not supported by <name>`; the tests that need the capability to act on skip.
3. Keep behaviour only this provider has in the adapter's own test class. Anything two providers share belongs in `Base`, behind a declared flag or hook.

Run the provider from an official Docker image, add a Docker Compose profile and a PHPUnit test suite for it, and run the suite as described in [CONTRIBUTING](/CONTRIBUTING.md#running-tests).

### Tips and Tricks 💡

Expand Down
13 changes: 13 additions & 0 deletions src/VCS/Adapter/Git.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,19 @@ public function getRepositoryPresignedUrl(string $owner, string $repositoryName,
throw new Exception('getRepositoryPresignedUrl() is not supported by ' . $this->getName());
}

/**
* Get a repository the installation reaches, by name.
*
* Only GitHub models installations, so the default reports it as
* unsupported.
*
* @return array<mixed>
*/
public function getInstallationRepository(string $repositoryName): array
{
throw new Exception('getInstallationRepository() is not supported by ' . $this->getName());
}

/**
* Create a check run for a commit.
*
Expand Down
5 changes: 0 additions & 5 deletions src/VCS/Adapter/Git/Bitbucket.php
Original file line number Diff line number Diff line change
Expand Up @@ -335,11 +335,6 @@ public function hasAccessToAllRepositories(): bool
return true;
}

public function getInstallationRepository(string $repositoryName): array
{
throw new Exception("getInstallationRepository is not applicable for this adapter");
}

public function searchRepositories(string $owner, int $page, int $per_page, string $search = ''): array
{
$url = "/repositories/{$owner}?page={$page}&pagelen={$per_page}";
Expand Down
16 changes: 11 additions & 5 deletions src/VCS/Adapter/Git/GitHub.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ public function createRepository(string $owner, string $repositoryName, bool $pr
*/
public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array
{
throw new Exception('Not implemented');
throw new Exception('createPullRequest() is not supported by ' . $this->getName());
}

/**
Expand Down Expand Up @@ -698,9 +698,15 @@ protected function generateAccessToken(string $privateKey, ?string $appId): void
*/
public function getUser(string $username): array
{
$response = $this->call(self::METHOD_GET, '/users/' . $username);
$response = $this->call(self::METHOD_GET, '/users/' . rawurlencode($username), ['Authorization' => "Bearer $this->accessToken"]);

return $response;
$responseHeaders = $response['headers'] ?? [];
$statusCode = $responseHeaders['status-code'] ?? 0;
if ($statusCode >= 400) {
throw new Exception("Failed to get user: HTTP {$statusCode}", $statusCode);
}

return $response['body'] ?? [];
}

/**
Expand Down Expand Up @@ -1454,11 +1460,11 @@ public function validateWebhookEvent(string $payload, string $signature, string

public function createTag(string $owner, string $repositoryName, string $tagName, string $target, string $message = ''): array
{
throw new Exception('createTag() is not implemented for GitHub');
throw new Exception('createTag() is not supported by ' . $this->getName());
}

public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array
{
throw new Exception('getCommitStatuses() is not implemented for GitHub');
throw new Exception('getCommitStatuses() is not supported by ' . $this->getName());
}
}
19 changes: 11 additions & 8 deletions src/VCS/Adapter/Git/GitLab.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,12 @@ public function getRepository(string $owner, string $repositoryName): array
$projectPath = urlencode("{$ownerPath}/{$repositoryName}");
$url = "/projects/{$projectPath}";

$response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]);
// GitLab redirects the old path of a renamed or deleted project, which no longer names it
$response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken], [], true, false);

$responseHeaders = $response['headers'] ?? [];
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
if ($responseHeadersStatusCode >= 400) {
if ($responseHeadersStatusCode !== 200) {
throw new RepositoryNotFound("Repository not found");
}

Expand Down Expand Up @@ -236,11 +237,6 @@ public function hasAccessToAllRepositories(): bool
return true;
}

public function getInstallationRepository(string $repositoryName): array
{
throw new Exception("getInstallationRepository is not applicable for this adapter");
}

/**
* List namespaces the current user can browse: their personal namespace
* plus every group they belong to. GitLab's own /namespaces endpoint
Expand Down Expand Up @@ -353,8 +349,12 @@ public function getRepositoryName(string $repositoryId): string

$responseHeaders = $response['headers'] ?? [];
$responseHeadersStatusCode = $responseHeaders['status-code'] ?? 0;
if ($responseHeadersStatusCode === 404) {
throw new RepositoryNotFound("Repository {$repositoryId} not found");
}

if ($responseHeadersStatusCode >= 400) {
throw new Exception("Repository {$repositoryId} not found");
throw new Exception("Failed to get repository {$repositoryId}: HTTP {$responseHeadersStatusCode}", $responseHeadersStatusCode);
}

$responseBody = $response['body'] ?? [];
Expand Down Expand Up @@ -688,6 +688,9 @@ public function getOwnerName(string $installationId, ?int $repositoryId = null):
$response = $this->call(self::METHOD_GET, $url, ['Authorization' => 'Bearer ' . $this->accessToken]);
$responseHeaders = $response['headers'] ?? [];
$statusCode = $responseHeaders['status-code'] ?? 0;
if ($statusCode === 404) {
throw new RepositoryNotFound("Repository {$repositoryId} not found");
}
if ($statusCode >= 400) {
throw new Exception("Failed to get owner name for repository {$repositoryId}: HTTP {$statusCode}", $statusCode);
}
Expand Down
22 changes: 7 additions & 15 deletions src/VCS/Adapter/Git/Gitea.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ class Gitea extends Git

public const CONTENTS_DIRECTORY = 'dir';

/**
* Gitea says 'synchronized' for a pushed head where consumers expect
* 'synchronize'; every other action passes through as sent.
*/
private const PULL_REQUEST_ACTION_MAP = ['synchronized' => 'synchronize'];

protected string $endpoint = 'http://gitea:3000/api/v1';

protected string $accessToken;
Expand Down Expand Up @@ -231,21 +237,6 @@ public function searchRepositories(string $owner, int $page, int $per_page, stri
];
}

/**
* Get installation repository
*
* Note: Gitea doesn't have GitHub App installations.
* This method is not applicable and throws an exception.
*
* @param string $repositoryName Name of the repository
* @return array<mixed>
* @throws Exception Always throws as installations don't exist in Gitea
*/
public function getInstallationRepository(string $repositoryName): array
{
throw new Exception("getInstallationRepository is not applicable for this adapter - use getRepository() with owner and repo name instead");
}

public function getRepository(string $owner, string $repositoryName): array
{
$url = "/repos/{$owner}/{$repositoryName}";
Expand Down Expand Up @@ -1206,6 +1197,7 @@ public function getEvents(string $event, string $payload): array
$branchUrl = !empty($repositoryUrl) && !empty($branch) ? $repositoryUrl . "/src/branch/" . $branch : '';
$pullRequestNumber = $payload['number'] ?? '';
$action = $payload['action'] ?? '';
$action = self::PULL_REQUEST_ACTION_MAP[$action] ?? $action;
$owner = $payloadRepositoryOwner['login'] ?? '';
$authorUrl = $payloadSender['html_url'] ?? '';
$authorAvatarUrl = $payloadPullRequestUser['avatar_url'] ?? '';
Expand Down
14 changes: 7 additions & 7 deletions src/VCS/Adapter/Git/Gogs.php
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ private function exec(string $command): string
*/
public function listRepositoryLanguages(string $owner, string $repositoryName): array
{
throw new Exception("Listing repository languages is not supported by Gogs");
throw new Exception('listRepositoryLanguages() is not supported by ' . $this->getName());
}

/**
Expand Down Expand Up @@ -474,7 +474,7 @@ public function createTag(string $owner, string $repositoryName, string $tagName
*/
public function createPullRequest(string $owner, string $repositoryName, string $title, string $head, string $base, string $body = ''): array
{
throw new Exception("Pull request API is not supported by Gogs");
throw new Exception('createPullRequest() is not supported by ' . $this->getName());
}

/**
Expand All @@ -484,7 +484,7 @@ public function createPullRequest(string $owner, string $repositoryName, string
*/
public function getPullRequest(string $owner, string $repositoryName, int $pullRequestNumber): array
{
throw new Exception("Pull request API is not supported by Gogs");
throw new Exception('getPullRequest() is not supported by ' . $this->getName());
}

/**
Expand All @@ -494,7 +494,7 @@ public function getPullRequest(string $owner, string $repositoryName, int $pullR
*/
public function getPullRequestFromBranch(string $owner, string $repositoryName, string $branch): array
{
throw new Exception("Pull request API is not supported by Gogs");
throw new Exception('getPullRequestFromBranch() is not supported by ' . $this->getName());
}

/**
Expand All @@ -504,7 +504,7 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName,
*/
public function getPullRequestFiles(string $owner, string $repositoryName, int $pullRequestNumber): array
{
throw new Exception("Pull request API is not supported by Gogs");
throw new Exception('getPullRequestFiles() is not supported by ' . $this->getName());
}

/**
Expand All @@ -514,7 +514,7 @@ public function getPullRequestFiles(string $owner, string $repositoryName, int $
*/
public function updateCommitStatus(string $repositoryName, string $commitHash, string $owner, string $state, string $description = '', string $target_url = '', string $context = ''): void
{
throw new Exception("Commit status API is not supported by Gogs");
throw new Exception('updateCommitStatus() is not supported by ' . $this->getName());
}

/**
Expand All @@ -526,7 +526,7 @@ public function updateCommitStatus(string $repositoryName, string $commitHash, s
*/
public function getCommitStatuses(string $owner, string $repositoryName, string $commitHash): array
{
throw new Exception("Commit status API is not supported by Gogs");
throw new Exception('getCommitStatuses() is not supported by ' . $this->getName());
}

/**
Expand Down
66 changes: 42 additions & 24 deletions tests/VCS/Adapter/BitbucketTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
use Utopia\Tests\Base;
use Utopia\VCS\Adapter\Git\Bitbucket;

class BitbucketTest extends Base
final class BitbucketTest extends Base
{
// Bitbucket routes by "workspace/slug" rather than a numeric id
protected const EVENT_REPOSITORY_ID = self::EVENT_OWNER . '/' . self::EVENT_REPOSITORY_NAME;
Expand All @@ -28,10 +28,26 @@ class BitbucketTest extends Base
protected static string $pushEventName = 'repo:push';
protected static string $pullRequestEventName = 'pullrequest:created';

/**
* Bitbucket names the action in the event rather than the payload, and
* has no reopen event.
*
* @var array<string, string>
*/
protected static array $pullRequestActions = [
'pullrequest:created' => 'opened',
'pullrequest:updated' => 'synchronize',
'pullrequest:fulfilled' => 'closed',
'pullrequest:rejected' => 'closed',
];

protected static bool $supportsInstallationRepository = false;
protected static bool $supportsRepositoryLanguages = false;
protected static bool $reportsAffectedFilesInPushEvent = false;

// A repository reports the one language it was labelled with, not the
// languages of the files it holds
protected static bool $detectsRepositoryLanguages = false;

// Bitbucket has no repository to resolve an owner from; getOwnerName()
// reports the account the token belongs to
protected static bool $resolvesOwnerFromRepositoryId = false;
Expand All @@ -40,7 +56,7 @@ class BitbucketTest extends Base
protected static bool $supportsNamespaceListing = false;

// Accounts are looked up by uuid, not by handle
protected static bool $supportsUserLookup = false;
protected static bool $resolvesUsersByHandle = false;

// Bitbucket Cloud can't reach a local test catcher
protected static bool $supportsWebhookDelivery = false;
Expand Down Expand Up @@ -82,6 +98,7 @@ protected function setupAdapter(): void
*
* @param array<string, mixed> $repository
*/
#[\Override]
protected function ownerOf(array $repository): string
{
$this->assertArrayHasKey('workspace', $repository);
Expand All @@ -91,7 +108,13 @@ protected function ownerOf(array $repository): string
return (string) $repository['workspace']['slug'];
}

protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false): string
#[\Override]
protected function pullRequestEventFor(string $action): string
{
return $action;
}

protected function pushPayload(string $branch, array $added = [], array $removed = [], array $modified = [], bool $created = false, bool $deleted = false, array $olderCommits = []): string
{
$ref = [
'type' => 'branch',
Expand All @@ -104,6 +127,16 @@ protected function pushPayload(string $branch, array $added = [], array $removed
],
];

// The adapter reads the head off new.target, so the commit list is
// there to prove the first commit listed is not taken for it
$commits = \array_map(fn (string $hash) => [
'hash' => $hash,
'message' => 'Older commit',
'author' => ['raw' => 'Older Author <older@example.com>'],
'links' => ['html' => ['href' => self::REPOSITORY_URL . '/commits/' . $hash]],
], $olderCommits);
$commits[] = $ref['target'];

// A created branch has no old state and a deleted one no new state. The
// file lists go unused, Bitbucket naming no files in a push.
return (string) json_encode([
Expand All @@ -115,12 +148,16 @@ protected function pushPayload(string $branch, array $added = [], array $removed
'closed' => $deleted,
'old' => $created ? null : $ref,
'new' => $deleted ? null : $ref,
'commits' => $commits,
]],
],
]);
}

protected function pullRequestPayload(bool $external = false): string
/**
* The event names the action, so the payload is the same for every one.
*/
protected function pullRequestPayload(bool $external = false, string $action = 'pullrequest:created'): string
{
return (string) json_encode([
'actor' => $this->eventActor(),
Expand Down Expand Up @@ -224,23 +261,4 @@ public function testGetEventsReportsEveryPushedBranch(): void

$this->assertSame([], $this->vcsAdapter->getEvents(static::$pushEventName, $tagsOnly));
}

public function testGetEventPullRequestActionMapping(): void
{
$mapping = [
'pullrequest:created' => 'opened',
'pullrequest:updated' => 'synchronize',
'pullrequest:fulfilled' => 'closed',
'pullrequest:rejected' => 'closed',
];

foreach ($mapping as $event => $action) {
$events = $this->vcsAdapter->getEvents($event, $this->pullRequestPayload());
$this->assertIsArray($events);
$this->assertCount(1, $events);
$result = $events[0];

$this->assertSame($action, $result['action'], "event '{$event}' should map to '{$action}'");
}
}
}
6 changes: 5 additions & 1 deletion tests/VCS/Adapter/ForgejoTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use Utopia\System\System;
use Utopia\VCS\Adapter\Git\Forgejo;

class ForgejoTest extends GiteaTest
final class ForgejoTest extends GiteaTest
{
protected static string $accessToken = '';

Expand All @@ -16,6 +16,10 @@ class ForgejoTest extends GiteaTest
protected static string $eventHeader = 'x-forgejo-event';
protected static string $signatureHeader = 'x-forgejo-signature';

// Forgejo's API user carries html_url, which Gitea 1.21's does not
protected static bool $reportsCommitAuthorUrl = true;

#[\Override]
protected function setupAdapter(): void
{
if (empty(static::$accessToken)) {
Expand Down
Loading