diff --git a/README.md b/README.md index 07929d5..5c733ca 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,14 @@ This ensures consistent Velox versions across different environments and team me + + + + + + ``` @@ -553,16 +561,52 @@ Each developer gets the correct binaries for their system: ``` -## GitHub API Rate Limits +## API Rate Limits Use a personal access token to avoid rate limits: ```bash GITHUB_TOKEN=your_token_here ./vendor/bin/dload get +GITLAB_TOKEN=your_token_here ./vendor/bin/dload get ``` Add to CI/CD environment variables for automated downloads. +## Gitlab CI configuration + +When you make a release in Gitlab, make sure to upload your assets to the release page via +package manager. This can easily be done via Gitlab CLI and the `glab release upload --use-package-registry` +command. + +``` +# .gitlab-ci.yml + +Build artifacts: + stage: push + script: + - mkdir bin + - echo "Mock binary for darwin arm" > bin/cool-darwin-arm64 + - echo "Mock binary for darwin amd" > bin/cool-darwin-amd64 + - echo "Mock binary for linux arm" > bin/cool-linux-arm64 + - echo "Mock binary for linux amd" > bin/cool-linux-amd64 + artifacts: + expire_in: 2 hours + paths: + - $CI_PROJECT_DIR/bin/cool-* + rules: + - if: $CI_COMMIT_TAG + +Release artifacts: + stage: deploy + image: gitlab/glab:latest + needs: [ "Build artifacts" ] + script: + - glab auth login --job-token $CI_JOB_TOKEN --hostname $CI_SERVER_HOST + - glab release upload --use-package-registry "$CI_COMMIT_TAG" ./bin/* + rules: + - if: $CI_COMMIT_TAG +``` + ## Contributing Contributions welcome! Submit Pull Requests to: diff --git a/src/Bootstrap.php b/src/Bootstrap.php index a85759c..a4bc5ff 100644 --- a/src/Bootstrap.php +++ b/src/Bootstrap.php @@ -14,6 +14,7 @@ use Internal\DLoad\Module\HttpClient\Factory; use Internal\DLoad\Module\HttpClient\Internal\NyholmFactoryImpl; use Internal\DLoad\Module\Repository\Internal\GitHub\Factory as GithubRepositoryFactory; +use Internal\DLoad\Module\Repository\Internal\GitLab\Factory as GitLabRepositoryFactory; use Internal\DLoad\Module\Repository\RepositoryProvider; use Internal\DLoad\Module\Velox\ApiClient; use Internal\DLoad\Module\Velox\Builder; @@ -106,7 +107,8 @@ public function withConfig( $this->container->bind( RepositoryProvider::class, static fn(Container $container): RepositoryProvider => (new RepositoryProvider()) - ->addRepositoryFactory($container->get(GithubRepositoryFactory::class)), + ->addRepositoryFactory($container->get(GithubRepositoryFactory::class)) + ->addRepositoryFactory($container->get(GitLabRepositoryFactory::class)), ); $this->container->bind(BinaryProvider::class, BinaryProviderImpl::class); $this->container->bind(Factory::class, NyholmFactoryImpl::class); diff --git a/src/Module/Config/Schema/GitLab.php b/src/Module/Config/Schema/GitLab.php new file mode 100644 index 0000000..e049139 --- /dev/null +++ b/src/Module/Config/Schema/GitLab.php @@ -0,0 +1,21 @@ + + */ + private array $defaultHeaders = [ + 'accept' => 'application/json', + ]; + + public function __construct( + private readonly HttpFactory $httpFactory, + private readonly ClientInterface $client, + private readonly GitLab $gitLabConfig, + ) { + // Add authorization header if token is available + $this->gitLabConfig->token !== null and $this->defaultHeaders['authorization'] = 'Bearer ' . $this->gitLabConfig->token; + } + + /** + * @throws GitLabRateLimitException + * @throws ClientExceptionInterface + */ + public function downloadArtifact(string|UriInterface $uri): ResponseInterface + { + $headers = []; + if ($this->gitLabConfig->token !== null) { + $headers = [ + 'PRIVATE-TOKEN' => $this->gitLabConfig->token, + ]; + } + + $request = $this->httpFactory->request(Method::Get, $uri, $headers); + + return $this->sendRequest($request); + } + + /** + * @param Method|non-empty-string $method + * @param array $headers + * @throws GitLabRateLimitException + * @throws ClientExceptionInterface + */ + public function request(Method|string $method, string|UriInterface $uri, array $headers = []): ResponseInterface + { + $request = $this->httpFactory->request($method, $uri, $headers + $this->defaultHeaders); + + return $this->sendRequest($request); + } + + /** + * @throws GitLabRateLimitException + * @throws ClientExceptionInterface + */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + $response = $this->client->sendRequest($request); + + if ($response->getStatusCode() === 429) { + throw new GitLabRateLimitException(); + } + + return $response; + } +} diff --git a/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php b/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php new file mode 100644 index 0000000..020aeb1 --- /dev/null +++ b/src/Module/Repository/Internal/GitLab/Api/RepositoryApi.php @@ -0,0 +1,179 @@ +repositoryPath = $projectPath; + } + + /** + * @param non-empty-string $repositoryPath + * @param non-empty-string $releaseName + * @param non-empty-string $fileName + * @throws GitLabRateLimitException + * @throws ClientExceptionInterface + */ + public function downloadArtifact(string $repositoryPath, string $releaseName, string $fileName): ResponseInterface + { + $url = \sprintf(self::URL_RELEASE_ASSET, \urlencode($repositoryPath), $releaseName, $fileName); + return $this->client->downloadArtifact($url); + } + + /** + * @param Method|non-empty-string $method + * @param array $headers + * @throws GitLabRateLimitException + * @throws ClientExceptionInterface + */ + public function request(Method|string $method, string|UriInterface $uri, array $headers = []): ResponseInterface + { + return $this->client->request($method, $uri, $headers); + } + + /** + * @throws GitLabRateLimitException + * @throws ClientExceptionInterface + */ + public function getRepository(): RepositoryInfo + { + $response = $this->request(Method::Get, \sprintf(self::URL_REPOSITORY, \urlencode($this->repositoryPath))); + + /** @var array{ + * name: string, + * name_with_namespace: string, + * description: string|null, + * web_url: string, + * visibility: bool, + * created_at: string, + * updated_at: string + * } $data */ + $data = \json_decode($response->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR); + + return RepositoryInfo::fromApiResponse($data); + } + + /** + * @param int<1, max> $page + * @return Paginator + * @throws GitLabRateLimitException + * @throws ClientExceptionInterface + */ + public function getReleases(int $page = 1): Paginator + { + $pageLoader = function () use ($page): \Generator { + $currentPage = $page; + + do { + try { + $response = $this->releasesRequest($currentPage); + + /** @var array + * }, + * upcoming_release: bool + * }> $data */ + $data = \json_decode($response->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR); + + // If empty response, no more pages + if ($data === []) { + return; + } + + $releases = []; + foreach ($data as $releaseData) { + try { + $releases[] = ReleaseInfo::fromApiResponse($releaseData); + } catch (\Throwable) { + // Skip invalid releases + continue; + } + } + + yield $releases; + + // Check if there are more pages + $hasMorePages = $this->hasNextPage($response); + $currentPage++; + } catch (ClientExceptionInterface) { + return; + } + } while ($hasMorePages); + }; + + return Paginator::createFromGenerator($pageLoader(), null); + } + + /** + * @param positive-int $page + * @throws GitLabRateLimitException + * @throws ClientExceptionInterface + */ + private function releasesRequest(int $page): ResponseInterface + { + return $this->request( + Method::Get, + $this->httpFactory->uri( + \sprintf(self::URL_RELEASES, \urlencode($this->repositoryPath)), + ['page' => $page], + ), + ); + } + + private function hasNextPage(ResponseInterface $response): bool + { + $headers = $response->getHeaders(); + $link = $headers['link'] ?? []; + + if (!isset($link[0])) { + return false; + } + + return \str_contains($link[0], 'rel="next"'); + } +} diff --git a/src/Module/Repository/Internal/GitLab/Api/Response/AssetInfo.php b/src/Module/Repository/Internal/GitLab/Api/Response/AssetInfo.php new file mode 100644 index 0000000..65544eb --- /dev/null +++ b/src/Module/Repository/Internal/GitLab/Api/Response/AssetInfo.php @@ -0,0 +1,42 @@ + $assets + */ + public function __construct( + public readonly string $name, + public readonly string $tagName, + public readonly \DateTimeImmutable $publishedAt, + public readonly array $assets, + public readonly bool $prerelease, + ) {} + + /** + * @param array{ + * name: non-empty-string|null, + * tag_name: non-empty-string, + * description: null|non-empty-string, + * created_at: non-empty-string, + * released_at: non-empty-string, + * assets: array{ + * links: list + * }, + * upcoming_release: bool + * } $data + */ + public static function fromApiResponse(array $data): self + { + $assets = []; + foreach ($data['assets']['links'] as $assetData) { + $assets[] = AssetInfo::fromApiResponse($assetData); + } + + return new self( + name: $data['name'] ?? $data['tag_name'], + tagName: $data['tag_name'], + publishedAt: new \DateTimeImmutable($data['released_at']), + assets: $assets, + prerelease: $data['upcoming_release'], + ); + } +} diff --git a/src/Module/Repository/Internal/GitLab/Api/Response/RepositoryInfo.php b/src/Module/Repository/Internal/GitLab/Api/Response/RepositoryInfo.php new file mode 100644 index 0000000..a0b2f32 --- /dev/null +++ b/src/Module/Repository/Internal/GitLab/Api/Response/RepositoryInfo.php @@ -0,0 +1,53 @@ +gitLabClient = new Client( + $httpFactory, + $httpFactory->client(), + $gitLabConfig, + ); + } + + public function supports(RepositoryConfig $config): bool + { + return \strtolower($config->type) === 'gitlab'; + } + + public function create(RepositoryConfig $config): GitLabRepository + { + $uri = \parse_url($config->uri, PHP_URL_PATH) ?? $config->uri; + $api = $this->createRepositoryApi($uri); + + return new GitLabRepository($api, $uri); + } + + /** + * @param non-empty-string $projectPath + */ + private function createRepositoryApi(string $projectPath): RepositoryApi + { + return new RepositoryApi($this->gitLabClient, $this->httpFactory, $projectPath); + } +} diff --git a/src/Module/Repository/Internal/GitLab/GitLabAsset.php b/src/Module/Repository/Internal/GitLab/GitLabAsset.php new file mode 100644 index 0000000..b4c9383 --- /dev/null +++ b/src/Module/Repository/Internal/GitLab/GitLabAsset.php @@ -0,0 +1,79 @@ +name, $dto->downloadUrl); + } + + /** + * @param null|\Closure(int $dlNow, int|null $dlSize, array $info): mixed $progress + * throwing any exceptions MUST abort the request; + * it MUST be called on DNS resolution, on arrival of headers and on completion; + * it SHOULD be called on upload/download of data and at least 1/s + * + * @return \Generator + * @throws ClientExceptionInterface + */ + public function download(?\Closure $progress = null): \Generator + { + $response = $this->api->downloadArtifact($this->release->getRepository()->getName(), $this->release->getName(), $this->getName()); + + $body = $response->getBody(); + $size = $body->getSize(); + $loaded = 0; + + while (!$body->eof()) { + $chunk = $body->read(8192); + $loaded += \strlen($chunk); + $progress === null or $progress($loaded, $size, []); + yield $chunk; + } + } + + public function destroy(): void + { + unset($this->release); + } +} diff --git a/src/Module/Repository/Internal/GitLab/GitLabRelease.php b/src/Module/Repository/Internal/GitLab/GitLabRelease.php new file mode 100644 index 0000000..d3f655b --- /dev/null +++ b/src/Module/Repository/Internal/GitLab/GitLabRelease.php @@ -0,0 +1,58 @@ +tagName); + $result = new self($repository, $dto->name, $version); + + $result->assets = AssetsCollection::create(static function () use ($api, $result, $dto): \Generator { + foreach ($dto->assets as $assetDTO) { + yield GitLabAsset::fromDTO($api, $result, $assetDTO); + } + }); + + return $result; + } + + public function destroy(): void + { + $this->assets === null or $this->assets->map( + static fn(object $asset) => $asset instanceof Destroyable and $asset->destroy(), + ); + + unset($this->assets, $this->repository); + } +} diff --git a/src/Module/Repository/Internal/GitLab/GitLabRepository.php b/src/Module/Repository/Internal/GitLab/GitLabRepository.php new file mode 100644 index 0000000..8e1c9c4 --- /dev/null +++ b/src/Module/Repository/Internal/GitLab/GitLabRepository.php @@ -0,0 +1,105 @@ +name = $projectPath; + } + + /** + * Returns a lazily loaded collection of repository releases. + * Pages are loaded only when needed during iteration or filtering. + */ + public function getReleases(): ReleasesCollection + { + if ($this->releases !== null) { + return $this->releases; + } + + // Create a generator function for lazy loading release pages + $pageLoader = function (): \Generator { + $page = 0; + + do { + try { + // to avoid first eager loading because of generator + yield []; + + $paginator = $this->api->getReleases(++$page); + $releases = $paginator->getPageItems(); + + $toYield = []; + foreach ($releases as $releaseDTO) { + try { + $toYield[] = GitLabRelease::fromDTO($this->api, $this, $releaseDTO); + } catch (\Throwable) { + // Skip invalid releases + continue; + } + } + yield $toYield; + + // Check if there are more pages by getting next page + $hasMorePages = $paginator->getNextPage() !== null; + } catch (GitLabRateLimitException $e) { + throw $e; + } catch (\Throwable) { + return; + } + } while ($hasMorePages); + }; + + // Create paginator + $paginator = \Internal\DLoad\Module\Repository\Internal\Paginator::createFromGenerator($pageLoader(), null); + + // Create a collection with the paginator + $this->releases = ReleasesCollection::create($paginator); + + return $this->releases; + } + + public function getName(): string + { + return $this->name; + } + + public function destroy(): void + { + $this->releases === null or $this->releases->map( + static fn(object $release) => $release instanceof Destroyable and $release->destroy(), + ); + + unset($this->releases); + } +}