diff --git a/doc/06-config.md b/doc/06-config.md index 85a138b6a..c6bbda8cb 100644 --- a/doc/06-config.md +++ b/doc/06-config.md @@ -246,6 +246,22 @@ private repositories which will later be cloned in GitLab CI jobs with a using HTTP basic auth. By default, Composer will generate a git-over-SSH URL for private repositories and HTTP(S) only for public. +## forgejo-domains + +Defaults to `["codeberg.org"]`. A list of domains of Forgejo servers. +This is used if you use the `forgejo` repository type. + +## forgejo-token + +A list of domain names and username/access-tokens to authenticate against them. For +example using `{"codeberg.org": {"username": "forgejo-user", "token": "access-token"}}` as the +value of this option will let Composer authenticate against codeberg.org. +Please note: If the package is not hosted at +codeberg.org the domain names must be also specified with the +[`forgejo-domains`](06-config.md#forgejo-domains) option. +Further info can also be found [here](articles/authentication-for-private-packages.md#forgejo-token) + + ## disable-tls Defaults to `false`. If set to true all HTTPS URLs will be tried with HTTP diff --git a/doc/articles/authentication-for-private-packages.md b/doc/articles/authentication-for-private-packages.md index fb86c5c23..36560a762 100644 --- a/doc/articles/authentication-for-private-packages.md +++ b/doc/articles/authentication-for-private-packages.md @@ -360,3 +360,41 @@ php composer.phar config [--global] --editor --auth } } ``` + +## forgejo-token + +> **Note:** For the forge authentication to work on private Forgejo instances, the +> [`forgejo-domains`](../06-config.md#forgejo-domains) section should also contain the URL. + +To create a new access token, go to your [applications section on Forgejo](https://codeberg.org/user/settings/applications) +(or the equivalent URL on your private instance) and create a new access token. See also [the Forgejo access token documentation](https://docs.codeberg.org/advanced/access-token/) for more information. + +When creating a Forgejo access token, make sure it has the `read:repository` scope. + +### Command line gitlab-token + +```shell +php composer.phar config [--global] forgejo-token.gitlab.example.org username access-token +``` + +In the above command, the config key `forgejo-token.forgejo.example.org` consists of two parts: + +- `forgejo-token` is the authentication method. +- `forgejo.example.org` is the host name of your Forgejo instance, you should replace it with the host name of your Forgejo instance or use `codeberg.org` if you don't have a self-hosted Forgejo instance. + +### Manual forgejo-token + +```shell +php composer.phar config [--global] --editor --auth +``` + +```json +{ + "forgejo-token": { + "forgejo.example.org": { + "username": "forgejo-user", + "token": "access-token" + } + } +} +``` diff --git a/src/Composer/Command/ConfigCommand.php b/src/Composer/Command/ConfigCommand.php index de3bd367e..c7bd4923a 100644 --- a/src/Composer/Command/ConfigCommand.php +++ b/src/Composer/Command/ConfigCommand.php @@ -210,7 +210,7 @@ EOT } if ($input->getOption('global') && !$this->authConfigFile->exists()) { touch($this->authConfigFile->getPath()); - $this->authConfigFile->write(['bitbucket-oauth' => new \ArrayObject, 'github-oauth' => new \ArrayObject, 'gitlab-oauth' => new \ArrayObject, 'gitlab-token' => new \ArrayObject, 'http-basic' => new \ArrayObject, 'bearer' => new \ArrayObject]); + $this->authConfigFile->write(['bitbucket-oauth' => new \ArrayObject, 'github-oauth' => new \ArrayObject, 'gitlab-oauth' => new \ArrayObject, 'gitlab-token' => new \ArrayObject, 'http-basic' => new \ArrayObject, 'bearer' => new \ArrayObject, 'forgejo-token' => new \ArrayObject()]); Silencer::call('chmod', $this->authConfigFile->getPath(), 0600); } @@ -838,7 +838,7 @@ EOT } // handle auth - if (Preg::isMatch('/^(bitbucket-oauth|github-oauth|gitlab-oauth|gitlab-token|http-basic|bearer)\.(.+)/', $settingKey, $matches)) { + if (Preg::isMatch('/^(bitbucket-oauth|github-oauth|gitlab-oauth|gitlab-token|http-basic|bearer|forgejo-token)\.(.+)/', $settingKey, $matches)) { if ($input->getOption('unset')) { $this->authConfigSource->removeConfigSetting($matches[1].'.'.$matches[2]); $this->configSource->removeConfigSetting($matches[1].'.'.$matches[2]); @@ -867,6 +867,12 @@ EOT } $this->configSource->removeConfigSetting($matches[1].'.'.$matches[2]); $this->authConfigSource->addConfigSetting($matches[1].'.'.$matches[2], ['username' => $values[0], 'password' => $values[1]]); + } elseif ($matches[1] === 'forgejo-token') { + if (2 !== count($values)) { + throw new \RuntimeException('Expected two arguments (username, access token), got '.count($values)); + } + $this->configSource->removeConfigSetting($matches[1].'.'.$matches[2]); + $this->authConfigSource->addConfigSetting($matches[1].'.'.$matches[2], ['username' => $values[0], 'token' => $values[1]]); } return 0; diff --git a/src/Composer/Config.php b/src/Composer/Config.php index f39579e06..039688824 100644 --- a/src/Composer/Config.php +++ b/src/Composer/Config.php @@ -86,6 +86,8 @@ class Config 'bearer' => [], 'bump-after-update' => false, 'allow-missing-requirements' => false, + 'forgejo-domains' => ['codeberg.org'], + 'forgejo-token' => [], ]; /** @var array */ @@ -191,7 +193,7 @@ class Config // override defaults with given config if (!empty($config['config']) && is_array($config['config'])) { foreach ($config['config'] as $key => $val) { - if (in_array($key, ['bitbucket-oauth', 'github-oauth', 'gitlab-oauth', 'gitlab-token', 'http-basic', 'bearer'], true) && isset($this->config[$key])) { + if (in_array($key, ['bitbucket-oauth', 'github-oauth', 'gitlab-oauth', 'gitlab-token', 'http-basic', 'bearer', 'forgejo-token'], true) && isset($this->config[$key])) { $this->config[$key] = array_merge($this->config[$key], $val); $this->setSourceOfConfigValue($val, $key, $source); } elseif (in_array($key, ['allow-plugins'], true) && isset($this->config[$key]) && is_array($this->config[$key]) && is_array($val)) { diff --git a/src/Composer/Config/JsonConfigSource.php b/src/Composer/Config/JsonConfigSource.php index 596d14f52..a596f19bd 100644 --- a/src/Composer/Config/JsonConfigSource.php +++ b/src/Composer/Config/JsonConfigSource.php @@ -100,7 +100,7 @@ class JsonConfigSource implements ConfigSourceInterface { $authConfig = $this->authConfig; $this->manipulateJson('addConfigSetting', static function (&$config, $key, $val) use ($authConfig): void { - if (Preg::isMatch('{^(bitbucket-oauth|github-oauth|gitlab-oauth|gitlab-token|bearer|http-basic|platform)\.}', $key)) { + if (Preg::isMatch('{^(bitbucket-oauth|github-oauth|gitlab-oauth|gitlab-token|bearer|http-basic|forgejo-token|platform)\.}', $key)) { [$key, $host] = explode('.', $key, 2); if ($authConfig) { $config[$key][$host] = $val; @@ -120,7 +120,7 @@ class JsonConfigSource implements ConfigSourceInterface { $authConfig = $this->authConfig; $this->manipulateJson('removeConfigSetting', static function (&$config, $key) use ($authConfig): void { - if (Preg::isMatch('{^(bitbucket-oauth|github-oauth|gitlab-oauth|gitlab-token|bearer|http-basic|platform)\.}', $key)) { + if (Preg::isMatch('{^(bitbucket-oauth|github-oauth|gitlab-oauth|gitlab-token|bearer|http-basic|forgejo-token|platform)\.}', $key)) { [$key, $host] = explode('.', $key, 2); if ($authConfig) { unset($config[$key][$host]); @@ -262,7 +262,7 @@ class JsonConfigSource implements ConfigSourceInterface $config['autoload-dev'][$prop] = new \stdClass; } } - foreach (['platform', 'http-basic', 'bearer', 'gitlab-token', 'gitlab-oauth', 'github-oauth', 'preferred-install'] as $prop) { + foreach (['platform', 'http-basic', 'bearer', 'gitlab-token', 'gitlab-oauth', 'github-oauth', 'forgejo-token', 'preferred-install'] as $prop) { if (isset($config['config'][$prop]) && $config['config'][$prop] === []) { $config['config'][$prop] = new \stdClass; } diff --git a/src/Composer/IO/BaseIO.php b/src/Composer/IO/BaseIO.php index 55ac16604..c603e6ca4 100644 --- a/src/Composer/IO/BaseIO.php +++ b/src/Composer/IO/BaseIO.php @@ -119,6 +119,7 @@ abstract class BaseIO implements IOInterface $githubOauth = $config->get('github-oauth'); $gitlabOauth = $config->get('gitlab-oauth'); $gitlabToken = $config->get('gitlab-token'); + $forgejoToken = $config->get('forgejo-token'); $httpBasic = $config->get('http-basic'); $bearerToken = $config->get('bearer'); @@ -163,6 +164,15 @@ abstract class BaseIO implements IOInterface $this->checkAndSetAuthentication($domain, $username, $password); } + foreach ($forgejoToken as $domain => $cred) { + if (!in_array($domain, $config->get('forgejo-domains'), true)) { + $this->debug($domain.' is not in the configured forgejo-domains, adding it implicitly as authentication is configured for this domain'); + $config->merge(['config' => ['forgejo-domains' => array_merge($config->get('forgejo-domains'), [$domain])]], 'implicit-due-to-auth'); + } + + $this->checkAndSetAuthentication($domain, $cred['username'], $cred['token']); + } + // reload http basic credentials from config if available foreach ($httpBasic as $domain => $cred) { $this->checkAndSetAuthentication($domain, $cred['username'], $cred['password']); diff --git a/src/Composer/Repository/Vcs/ForgejoDriver.php b/src/Composer/Repository/Vcs/ForgejoDriver.php new file mode 100644 index 000000000..555727f72 --- /dev/null +++ b/src/Composer/Repository/Vcs/ForgejoDriver.php @@ -0,0 +1,321 @@ + Map of tag name to identifier */ + private $tags; + /** @var array Map of branch name to identifier */ + private $branches; + + public function initialize(): void + { + $this->forgejoUrl = ForgejoUrl::create($this->url); + $this->originUrl = $this->forgejoUrl->originUrl; + + $this->cache = new Cache($this->io, $this->config->get('cache-repo-dir').'/'.$this->originUrl.'/'.$this->forgejoUrl->owner.'/'.$this->forgejoUrl->repository); + $this->cache->setReadOnly($this->config->get('cache-read-only')); + + $this->fetchRepositoryData(); + } + + public function getFileContent(string $file, string $identifier): ?string + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getFileContent($file, $identifier); + } + + $resource = $this->forgejoUrl->apiUrl.'/contents/' . $file . '?ref='.urlencode($identifier); + $resource = $this->getContents($resource)->decodeJson(); + + // The Forgejo contents API only returns files up to 1MB as base64 encoded files + // larger files either need be fetched with a raw accept header or by using the git blob endpoint + if ((!isset($resource['content']) || $resource['content'] === '') && $resource['encoding'] === 'none' && isset($resource['git_url'])) { + $resource = $this->getContents($resource['git_url'])->decodeJson(); + } + + if (!isset($resource['content']) || $resource['encoding'] !== 'base64' || false === ($content = base64_decode($resource['content'], true))) { + throw new \RuntimeException('Could not retrieve ' . $file . ' for '.$identifier); + } + + return $content; + } + + public function getChangeDate(string $identifier): ?\DateTimeImmutable + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getChangeDate($identifier); + } + + $resource = $this->forgejoUrl->apiUrl.'/git/commits/'.urlencode($identifier).'?verification=false&files=false'; + $commit = $this->getContents($resource)->decodeJson(); + + return new \DateTimeImmutable($commit['commit']['committer']['date']); + } + + public function getRootIdentifier(): string + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getRootIdentifier(); + } + + return $this->repositoryData->defaultBranch; + } + + public function getBranches(): array + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getBranches(); + } + + if (null === $this->branches) { + $branches = []; + $resource = $this->forgejoUrl->apiUrl.'/branches?per_page=100'; + + do { + $response = $this->getContents($resource); + $branchData = $response->decodeJson(); + foreach ($branchData as $branch) { + $branches[$branch['name']] = $branch['commit']['id']; + } + + $resource = $this->getNextPage($response); + } while ($resource); + + $this->branches = $branches; + } + + return $this->branches; + } + + public function getTags(): array + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getTags(); + } + if (null === $this->tags) { + $tags = []; + $resource = $this->forgejoUrl->apiUrl.'/tags?per_page=100'; + + do { + $response = $this->getContents($resource); + $tagsData = $response->decodeJson(); + foreach ($tagsData as $tag) { + $tags[$tag['tag_name']] = $tag['commit']['sha']; + } + + $resource = $this->getNextPage($response); + } while ($resource); + + $this->tags = $tags; + } + + return $this->tags; + } + + public function getDist(string $identifier): ?array + { + $url = $this->forgejoUrl->apiUrl.'/archive/'.$identifier.'.zip'; + + return ['type' => 'zip', 'url' => $url, 'reference' => $identifier, 'shasum' => '']; + } + + public function getComposerInformation(string $identifier): ?array + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getComposerInformation($identifier); + } + + if (!isset($this->infoCache[$identifier])) { + if ($this->shouldCache($identifier) && false !== ($res = $this->cache->read($identifier))) { + $composer = JsonFile::parseJson($res); + } else { + $composer = $this->getBaseComposerInformation($identifier); + + if ($this->shouldCache($identifier)) { + $this->cache->write($identifier, (string) json_encode($composer)); + } + } + + if ($composer !== null) { + // specials for forgejo + if (isset($composer['support']) && !is_array($composer['support'])) { + $composer['support'] = []; + } + if (!isset($composer['support']['source'])) { + if (false !== ($label = array_search($identifier, $this->getTags(), true))) { + $composer['support']['source'] = $this->repositoryData->htmlUrl.'/tag/' . $label; + } elseif (false !== ($label = array_search($identifier, $this->getBranches(), true))) { + $composer['support']['source'] = $this->repositoryData->htmlUrl.'/branch/'.$label; + } else { + $composer['support']['source'] = $this->repositoryData->htmlUrl.'/commit/'.$identifier; + } + } + if (!isset($composer['support']['issues']) && $this->repositoryData->hasIssues) { + $composer['support']['issues'] = $this->repositoryData->htmlUrl.'/issues'; + } + if (!isset($composer['abandoned']) && $this->repositoryData->isArchived) { + $composer['abandoned'] = true; + } + } + + $this->infoCache[$identifier] = $composer; + } + + return $this->infoCache[$identifier]; + } + + public function getSource(string $identifier): array + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getSource($identifier); + } + if ($this->repositoryData->isPrivate) { + // Private Forgejo repositories should be accessed using the + // SSH version of the URL. + $url = $this->repositoryData->sshUrl; + } else { + $url = $this->getUrl(); + } + + return ['type' => 'git', 'url' => $url, 'reference' => $identifier]; + } + + public function getUrl(): string + { + if ($this->gitDriver !== null) { + return $this->gitDriver->getUrl(); + } + + return $this->repositoryData->isPrivate ? $this->repositoryData->sshUrl : $this->repositoryData->httpCloneUrl; + } + + public static function supports(IOInterface $io, Config $config, string $url, bool $deep = false): bool + { + $forgejoUrl = ForgejoUrl::tryFrom($url); + if ($forgejoUrl === null) { + return false; + } + + if (!in_array(strtolower($forgejoUrl->originUrl), $config->get('forgejo-domains'), true)) { + return false; + } + + if (!extension_loaded('openssl')) { + $io->writeError('Skipping Forgejo driver for '.$url.' because the OpenSSL PHP extension is missing.', true, IOInterface::VERBOSE); + + return false; + } + + return true; + } + + protected function setupGitDriver(string $url): void + { + $this->gitDriver = new GitDriver( + ['url' => $url], + $this->io, + $this->config, + $this->httpDownloader, + $this->process + ); + $this->gitDriver->initialize(); + } + + private function fetchRepositoryData(): void + { + if ($this->repositoryData !== null) { + return; + } + + $data = $this->getContents($this->forgejoUrl->apiUrl, true)->decodeJson(); + + if (null === $data && null !== $this->gitDriver) { + return; + } + + $this->repositoryData = ForgejoRepositoryData::fromRemoteData($data); + } + + protected function getNextPage(Response $response): ?string + { + $header = $response->getHeader('link'); + if ($header === null) { + return null; + } + + $links = explode(',', $header); + foreach ($links as $link) { + if (Preg::isMatch('{<(.+?)>; *rel="next"}', $link, $match)) { + return $match[1]; + } + } + + return null; + } + + protected function getContents(string $url, bool $fetchingRepoData = false): Response + { + try { + return parent::getContents($url); + } catch (TransportException $e) { + switch ($e->getCode()) { + case 404: + // try to authorize only if we are fetching the main /repos/foo/bar data, otherwise it must be a real 404 + if (!$fetchingRepoData) { + throw $e; + } + + if (!$this->io->isInteractive()) { + $this->attemptCloneFallback(); + + return new Response(['url' => 'dummy'], 200, [], 'null'); + } + default: + throw $e; + } + } + } + + /** + * @phpstan-impure + * + * @return true + * @throws \RuntimeException + */ + protected function attemptCloneFallback(): bool + { + try { + // If this repository may be private (hard to say for sure, + // Forgejo returns 404 for private repositories) and we + // cannot ask for authentication credentials (because we + // are not interactive) then we fallback to GitDriver. + $this->setupGitDriver($this->forgejoUrl->generateSshUr()); + + return true; + } catch (\RuntimeException $e) { + $this->gitDriver = null; + + $this->io->writeError('Failed to clone the '.$this->forgejoUrl->generateSshUr().' repository, try running in interactive mode so that you can enter your Forgejo credentials'); + throw $e; + } + } +} diff --git a/src/Composer/Repository/Vcs/VcsDriver.php b/src/Composer/Repository/Vcs/VcsDriver.php index b780304f3..69b98faa7 100644 --- a/src/Composer/Repository/Vcs/VcsDriver.php +++ b/src/Composer/Repository/Vcs/VcsDriver.php @@ -75,6 +75,7 @@ abstract class VcsDriver implements VcsDriverInterface /** * Returns whether or not the given $identifier should be cached or not. + * @phpstan-assert-if-true !null $this->cache */ protected function shouldCache(string $identifier): bool { diff --git a/src/Composer/Repository/VcsRepository.php b/src/Composer/Repository/VcsRepository.php index 5aaea602d..0fc628e7b 100644 --- a/src/Composer/Repository/VcsRepository.php +++ b/src/Composer/Repository/VcsRepository.php @@ -83,6 +83,7 @@ class VcsRepository extends ArrayRepository implements ConfigurableRepositoryInt 'gitlab' => 'Composer\Repository\Vcs\GitLabDriver', 'bitbucket' => 'Composer\Repository\Vcs\GitBitbucketDriver', 'git-bitbucket' => 'Composer\Repository\Vcs\GitBitbucketDriver', + 'forgejo' => 'Composer\Repository\Vcs\ForgejoDriver', 'git' => 'Composer\Repository\Vcs\GitDriver', 'hg' => 'Composer\Repository\Vcs\HgDriver', 'perforce' => 'Composer\Repository\Vcs\PerforceDriver', diff --git a/src/Composer/Util/ForgejoRepositoryData.php b/src/Composer/Util/ForgejoRepositoryData.php new file mode 100644 index 000000000..2a45e238d --- /dev/null +++ b/src/Composer/Util/ForgejoRepositoryData.php @@ -0,0 +1,59 @@ +htmlUrl = $htmlUrl; + $this->httpCloneUrl = $httpCloneUrl; + $this->sshUrl = $sshUrl; + $this->isPrivate = $isPrivate; + $this->defaultBranch = $defaultBranch; + $this->hasIssues = $hasIssues; + $this->isArchived = $isArchived; + } + + /** + * @param array $data + */ + public static function fromRemoteData(array $data): self + { + return new self( + $data['html_url'], + $data['clone_url'], + $data['ssh_url'], + $data['private'], + $data['default_branch'], + $data['has_issues'], + $data['archived'] + ); + } +} diff --git a/src/Composer/Util/ForgejoUrl.php b/src/Composer/Util/ForgejoUrl.php new file mode 100644 index 000000000..263adada5 --- /dev/null +++ b/src/Composer/Util/ForgejoUrl.php @@ -0,0 +1,67 @@ +owner = $owner; + $this->repository = $repository; + $this->originUrl = $originUrl; + $this->apiUrl = $apiUrl; + } + + public static function create(string $repoUrl): self + { + $url = self::tryFrom($repoUrl); + if ($url !== null) { + return $url; + } + + throw new \InvalidArgumentException('This is not a valid Forgejo URL: ' . $repoUrl); + } + + public static function tryFrom(?string $repoUrl): ?self + { + if ($repoUrl === null || ! Preg::isMatch(self::URL_REGEX, $repoUrl, $match)) { + return null; + } + + $originUrl = strtolower($match[1] ?? (string) $match[2]); + $apiBase = $originUrl . '/api/v1'; + + return new self( + $match[3], + $match[4], + $originUrl, + sprintf('https://%s/repos/%s/%s', $apiBase, $match[3], $match[4]) + ); + } + + public function generateSshUr(): string + { + return 'git@' . $this->originUrl . ':'.$this->owner.'/'.$this->repository.'.git'; + } +} diff --git a/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php b/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php new file mode 100644 index 000000000..a107c11a3 --- /dev/null +++ b/tests/Composer/Test/Repository/Vcs/ForgejoDriverTest.php @@ -0,0 +1,191 @@ +home = self::getUniqueTmpDirectory(); + $this->config = new Config(); + $this->config->merge([ + 'config' => [ + 'home' => $this->home, + 'forgejo-domains' => ['codeberg.org'], + ], + ]); + + $this->io = $this->getMockBuilder('Composer\IO\IOInterface')->getMock(); + $this->httpDownloader = $this->getHttpDownloaderMock($this->io, $this->config); + } + + protected function tearDown(): void + { + parent::tearDown(); + $fs = new Filesystem; + $fs->removeDirectory($this->home); + } + + public function testPublicRepository(): void + { + $this->expectInteractiveIO(); + + $this->httpDownloader->expects( + [ + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo', 'body' => (string) json_encode([ + 'default_branch' => 'main', + 'has_issues' => true, + 'archived' => false, + 'private' => false, + 'html_url' => 'https://codeberg.org/acme/repo', + 'ssh_url' => 'git@codeberg.org:acme/repo.git', + 'clone_url' => 'https://codeberg.org/acme/repo.git' + ])], + ], + true + ); + + $driver = $this->initializeDriver('https://codeberg.org/acme/repo.git'); + self::assertEquals('main', $driver->getRootIdentifier()); + + $sha = 'SOMESHA'; + $dist = $driver->getDist($sha); + self::assertIsArray($dist); + self::assertEquals('zip', $dist['type']); + self::assertEquals('https://codeberg.org/api/v1/repos/acme/repo/archive/SOMESHA.zip', $dist['url']); + self::assertEquals($sha, $dist['reference']); + + $source = $driver->getSource($sha); + self::assertEquals('git', $source['type']); + self::assertEquals('https://codeberg.org/acme/repo.git', $source['url']); + self::assertEquals($sha, $source['reference']); + } + + public function testGetBranches(): void + { + $this->expectInteractiveIO(); + + $this->httpDownloader->expects( + [ + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo', 'body' => (string) json_encode([ + 'default_branch' => 'main', + 'has_issues' => true, + 'archived' => false, + 'private' => false, + 'html_url' => 'https://codeberg.org/acme/repo', + 'ssh_url' => 'git@codeberg.org:acme/repo.git', + 'clone_url' => 'https://codeberg.org/acme/repo.git' + ])], + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo/branches?per_page=100', 'body' => (string) json_encode([ + ['name' => 'main', 'commit' => ['id' => 'SOMESHA']], + ])] + ], + true + ); + + $driver = $this->initializeDriver('https://codeberg.org/acme/repo.git'); + self::assertEquals(['main' => 'SOMESHA'], $driver->getBranches()); + } + + public function testGetTags(): void + { + $this->expectInteractiveIO(); + + $this->httpDownloader->expects( + [ + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo', 'body' => (string) json_encode([ + 'default_branch' => 'main', + 'has_issues' => true, + 'archived' => false, + 'private' => false, + 'html_url' => 'https://codeberg.org/acme/repo', + 'ssh_url' => 'git@codeberg.org:acme/repo.git', + 'clone_url' => 'https://codeberg.org/acme/repo.git' + ])], + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo/tags?per_page=100', 'body' => (string) json_encode([ + ['tag_name' => '1.0', 'commit' => ['sha' => 'SOMESHA']] + ])] + ], + true + ); + + $driver = $this->initializeDriver('https://codeberg.org/acme/repo.git'); + self::assertEquals(['1.0' => 'SOMESHA'], $driver->getTags()); + } + + public function testGetEmptyFileContent(): void + { + $this->expectInteractiveIO(); + + $this->httpDownloader->expects( + [ + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo', 'body' => (string) json_encode([ + 'default_branch' => 'main', + 'has_issues' => true, + 'archived' => false, + 'private' => false, + 'html_url' => 'https://codeberg.org/acme/repo', + 'ssh_url' => 'git@codeberg.org:acme/repo.git', + 'clone_url' => 'https://codeberg.org/acme/repo.git' + ])], + ['url' => 'https://codeberg.org/api/v1/repos/acme/repo/contents/composer.json?ref=main', 'body' => '{"encoding":"base64","content":""}'] + ], + true + ); + + $driver = $this->initializeDriver('https://codeberg.org/acme/repo.git'); + + self::assertSame('', $driver->getFileContent('composer.json', 'main')); + } + + /** + * @dataProvider supportsProvider + */ + public function testSupports(bool $expected, string $repoUrl): void + { + self::assertSame($expected, ForgejoDriver::supports($this->io, $this->config, $repoUrl)); + } + + /** + * @return list + */ + public static function supportsProvider(): array + { + return [ + [false, 'https://example.org/acme/repo'], + [true, 'https://codeberg.org/acme/repository'], + ]; + } + + private function initializeDriver(string $repoUrl): ForgejoDriver + { + $driver = new ForgejoDriver(['url' => $repoUrl], $this->io, $this->config, $this->httpDownloader, $this->getProcessExecutorMock()); + $driver->initialize(); + + return $driver; + } + + private function expectInteractiveIO(bool $isInteractive = true): void + { + $this->io->expects($this->any()) + ->method('isInteractive') + ->will($this->returnValue($isInteractive)); + } +} diff --git a/tests/Composer/Test/Util/ForgejoUrlTest.php b/tests/Composer/Test/Util/ForgejoUrlTest.php new file mode 100644 index 000000000..2fc4889a0 --- /dev/null +++ b/tests/Composer/Test/Util/ForgejoUrlTest.php @@ -0,0 +1,46 @@ +assertNotNull($forgejoUrl); + $this->assertSame('codeberg.org', $forgejoUrl->originUrl); + $this->assertSame('acme', $forgejoUrl->owner); + $this->assertSame('repo', $forgejoUrl->repository); + $this->assertSame('https://codeberg.org/api/v1/repos/acme/repo', $forgejoUrl->apiUrl); + } + + public static function createProvider(): array + { + return [ + ['git@codeberg.org:acme/repo.git'], + ['https://codeberg.org/acme/repo'], + ['https://codeberg.org/acme/repo.git'], + ]; + } + + public function testCreateInvalid(): void + { + $this->expectException(\InvalidArgumentException::class); + + ForgejoUrl::create('https://example.org'); + } + + public function testGenerateSshUrl(): void + { + $forgejoUrl = ForgejoUrl::create('git@codeberg.org:acme/repo.git'); + + $this->assertSame('git@codeberg.org:acme/repo.git', $forgejoUrl->generateSshUr()); + } +}