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
17 changes: 13 additions & 4 deletions packages/vcs/docs/add-new-vcs-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ Below are outlined the most useful files for adding a new VCS adapter:
│  │  └── Git.php # Parent class for Git-based adapters
│   └── Adapter.php # Parent class for individual adapters
└── tests
└── VCS
├── Adapter/ # Where tests of your new adapter go!
└── Base.php # Parent class that holds all tests
├── Unit # Payload parsing, on a bare host
│ ├── Base.php # Parent class that holds the webhook contract
│ └── <Adapter>Test.php # Where the offline tests of your new adapter go!
└── E2E # Calls against a running provider
├── Base.php # Parent class that holds all the rest
└── <Adapter>Test.php # Where the live tests of your new adapter go!
```
### Extend the adapter 💻

Expand Down Expand Up @@ -95,7 +98,13 @@ rather than `getEvent()` — the latter reports only the first event of a batch.

### Testing the adapter 🛠️

The suite runs in two tiers. Put the webhook payload fixtures and the parsing assertions your adapter answers offline in `tests/Unit/<Adapter>Test.php`, and the calls against a running provider in `tests/E2E/<Adapter>Test.php`. A provider you can self-host joins `docker-compose.yml` as an official image on an offset host port, with a bootstrap that mints an access token into `tests/.tokens`; see the [README](/README.md#tests) for how to run both tiers.
The suite runs in two tiers, and every adapter runs the same tests in each. Each `Base` holds them and each adapter's class declares how its provider differs, so a new adapter is written as declarations rather than as tests of its own:

1. Extend `Utopia\Tests\Unit\Base` in `tests/Unit/<Adapter>Test.php` and implement its hooks: `createAdapter()` builds the adapter against no provider, `signWebhookPayload()` signs a payload the way the provider does, and `pushPayload()` and `pullRequestPayload()` build webhook payloads shaped the way the provider sends them. Declare `$pullRequestActions` where the provider names its pull request actions differently from GitHub, whose names are the shared vocabulary.
2. Extend `Utopia\Tests\E2E\Base` in `tests/E2E/<Adapter>Test.php` and implement `setupAdapter()` and `anonymousCloneUrl()`. 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.

A provider you can self-host joins `docker-compose.yml` as an official image on an offset host port, with a bootstrap that mints an access token into `tests/.tokens`; see the [README](/README.md#tests) for how to run both tiers.

### Tips and tricks 💡

Expand Down
13 changes: 13 additions & 0 deletions packages/vcs/src/VCS/Adapter/Git.php
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,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 packages/vcs/src/VCS/Adapter/Git/Bitbucket.php
Original file line number Diff line number Diff line change
Expand Up @@ -330,11 +330,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: 12 additions & 4 deletions packages/vcs/src/VCS/Adapter/Git/GitHub.php
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,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 @@ -682,7 +682,15 @@ protected function generateAccessToken(string $privateKey, ?string $appId): void
*/
public function getUser(string $username): array
{
return $this->call(self::METHOD_GET, '/users/' . $username);
$response = $this->call(self::METHOD_GET, '/users/' . rawurlencode($username), ['Authorization' => "Bearer $this->accessToken"]);

$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 @@ -1430,11 +1438,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 packages/vcs/src/VCS/Adapter/Git/GitLab.php
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,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 @@ -229,11 +230,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 @@ -346,8 +342,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 @@ -681,6 +681,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 packages/vcs/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 array PULL_REQUEST_ACTION_MAP = ['synchronized' => 'synchronize'];

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

protected string $accessToken;
Expand Down Expand Up @@ -224,21 +230,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 @@ -1196,6 +1187,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 packages/vcs/src/VCS/Adapter/Git/Gogs.php
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ private function exec(string $command): string
#[\Override]
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 @@ -492,7 +492,7 @@ public function createTag(string $owner, string $repositoryName, string $tagName
#[\Override]
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 @@ -503,7 +503,7 @@ public function createPullRequest(string $owner, string $repositoryName, string
#[\Override]
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 @@ -514,7 +514,7 @@ public function getPullRequest(string $owner, string $repositoryName, int $pullR
#[\Override]
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 @@ -525,7 +525,7 @@ public function getPullRequestFromBranch(string $owner, string $repositoryName,
#[\Override]
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 @@ -536,7 +536,7 @@ public function getPullRequestFiles(string $owner, string $repositoryName, int $
#[\Override]
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 @@ -549,7 +549,7 @@ public function updateCommitStatus(string $repositoryName, string $commitHash, s
#[\Override]
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
Loading
Loading