Skip to content

Commit d68a2a1

Browse files
authored
feat: Add support for Gitlab (#92)
1 parent ec998fc commit d68a2a1

File tree

13 files changed

+815
-2
lines changed

13 files changed

+815
-2
lines changed

README.md

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,14 @@ This ensures consistent Velox versions across different environments and team me
463463
<repository type="github" uri="vimeo/psalm" />
464464
<binary name="psalm.phar" pattern="/^psalm\.phar$/" />
465465
</software>
466+
467+
<!-- GitLab repository -->
468+
<software name="My cool project" alias="cool-project"
469+
homepage="https://gitlab.com/path/to/my/repository"
470+
description="">
471+
<repository type="gitlab" uri="path/to/my/repository" asset-pattern="/^cool-.*/" />
472+
<binary name="cool" pattern="/^cool-.*/" />
473+
</software>
466474
</registry>
467475
</dload>
468476
```
@@ -553,16 +561,52 @@ Each developer gets the correct binaries for their system:
553561
</actions>
554562
```
555563
556-
## GitHub API Rate Limits
564+
## API Rate Limits
557565
558566
Use a personal access token to avoid rate limits:
559567
560568
```bash
561569
GITHUB_TOKEN=your_token_here ./vendor/bin/dload get
570+
GITLAB_TOKEN=your_token_here ./vendor/bin/dload get
562571
```
563572
564573
Add to CI/CD environment variables for automated downloads.
565574
575+
## Gitlab CI configuration
576+
577+
When you make a release in Gitlab, make sure to upload your assets to the release page via
578+
package manager. This can easily be done via Gitlab CLI and the `glab release upload --use-package-registry`
579+
command.
580+
581+
```
582+
# .gitlab-ci.yml
583+
584+
Build artifacts:
585+
stage: push
586+
script:
587+
- mkdir bin
588+
- echo "Mock binary for darwin arm" > bin/cool-darwin-arm64
589+
- echo "Mock binary for darwin amd" > bin/cool-darwin-amd64
590+
- echo "Mock binary for linux arm" > bin/cool-linux-arm64
591+
- echo "Mock binary for linux amd" > bin/cool-linux-amd64
592+
artifacts:
593+
expire_in: 2 hours
594+
paths:
595+
- $CI_PROJECT_DIR/bin/cool-*
596+
rules:
597+
- if: $CI_COMMIT_TAG
598+
599+
Release artifacts:
600+
stage: deploy
601+
image: gitlab/glab:latest
602+
needs: [ "Build artifacts" ]
603+
script:
604+
- glab auth login --job-token $CI_JOB_TOKEN --hostname $CI_SERVER_HOST
605+
- glab release upload --use-package-registry "$CI_COMMIT_TAG" ./bin/*
606+
rules:
607+
- if: $CI_COMMIT_TAG
608+
```
609+
566610
## Contributing
567611
568612
Contributions welcome! Submit Pull Requests to:

src/Bootstrap.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Internal\DLoad\Module\HttpClient\Factory;
1515
use Internal\DLoad\Module\HttpClient\Internal\NyholmFactoryImpl;
1616
use Internal\DLoad\Module\Repository\Internal\GitHub\Factory as GithubRepositoryFactory;
17+
use Internal\DLoad\Module\Repository\Internal\GitLab\Factory as GitLabRepositoryFactory;
1718
use Internal\DLoad\Module\Repository\RepositoryProvider;
1819
use Internal\DLoad\Module\Velox\ApiClient;
1920
use Internal\DLoad\Module\Velox\Builder;
@@ -106,7 +107,8 @@ public function withConfig(
106107
$this->container->bind(
107108
RepositoryProvider::class,
108109
static fn(Container $container): RepositoryProvider => (new RepositoryProvider())
109-
->addRepositoryFactory($container->get(GithubRepositoryFactory::class)),
110+
->addRepositoryFactory($container->get(GithubRepositoryFactory::class))
111+
->addRepositoryFactory($container->get(GitLabRepositoryFactory::class)),
110112
);
111113
$this->container->bind(BinaryProvider::class, BinaryProviderImpl::class);
112114
$this->container->bind(Factory::class, NyholmFactoryImpl::class);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Internal\DLoad\Module\Config\Schema;
6+
7+
use Internal\DLoad\Module\Common\Internal\Attribute\Env;
8+
9+
/**
10+
* GitLab API configuration.
11+
*
12+
* Contains authentication settings for GitLab API access.
13+
*
14+
* @internal
15+
*/
16+
final class GitLab
17+
{
18+
/** @var string|null $token API token for GitLab authentication */
19+
#[Env('GITLAB_TOKEN')]
20+
public ?string $token = null;
21+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Internal\DLoad\Module\Repository\Internal\GitLab\Api;
6+
7+
use Internal\DLoad\Module\Config\Schema\GitLab;
8+
use Internal\DLoad\Module\HttpClient\Factory as HttpFactory;
9+
use Internal\DLoad\Module\HttpClient\Method;
10+
use Internal\DLoad\Module\Repository\Internal\GitLab\Exception\GitLabRateLimitException;
11+
use Psr\Http\Client\ClientExceptionInterface;
12+
use Psr\Http\Client\ClientInterface;
13+
use Psr\Http\Message\RequestInterface;
14+
use Psr\Http\Message\ResponseInterface;
15+
use Psr\Http\Message\UriInterface;
16+
17+
/**
18+
* HTTP client wrapper with GitLab-specific error handling and authentication.
19+
*
20+
* Detects and handles GitLab Rate Limit responses automatically.
21+
* Adds GitLab API token authentication when available.
22+
*
23+
* @internal
24+
* @psalm-internal Internal\DLoad\Module\Repository\Internal\GitLab
25+
*/
26+
final class Client
27+
{
28+
/**
29+
* @var array<non-empty-string, non-empty-string>
30+
*/
31+
private array $defaultHeaders = [
32+
'accept' => 'application/json',
33+
];
34+
35+
public function __construct(
36+
private readonly HttpFactory $httpFactory,
37+
private readonly ClientInterface $client,
38+
private readonly GitLab $gitLabConfig,
39+
) {
40+
// Add authorization header if token is available
41+
$this->gitLabConfig->token !== null and $this->defaultHeaders['authorization'] = 'Bearer ' . $this->gitLabConfig->token;
42+
}
43+
44+
/**
45+
* @throws GitLabRateLimitException
46+
* @throws ClientExceptionInterface
47+
*/
48+
public function downloadArtifact(string|UriInterface $uri): ResponseInterface
49+
{
50+
$headers = [];
51+
if ($this->gitLabConfig->token !== null) {
52+
$headers = [
53+
'PRIVATE-TOKEN' => $this->gitLabConfig->token,
54+
];
55+
}
56+
57+
$request = $this->httpFactory->request(Method::Get, $uri, $headers);
58+
59+
return $this->sendRequest($request);
60+
}
61+
62+
/**
63+
* @param Method|non-empty-string $method
64+
* @param array<string, string> $headers
65+
* @throws GitLabRateLimitException
66+
* @throws ClientExceptionInterface
67+
*/
68+
public function request(Method|string $method, string|UriInterface $uri, array $headers = []): ResponseInterface
69+
{
70+
$request = $this->httpFactory->request($method, $uri, $headers + $this->defaultHeaders);
71+
72+
return $this->sendRequest($request);
73+
}
74+
75+
/**
76+
* @throws GitLabRateLimitException
77+
* @throws ClientExceptionInterface
78+
*/
79+
public function sendRequest(RequestInterface $request): ResponseInterface
80+
{
81+
$response = $this->client->sendRequest($request);
82+
83+
if ($response->getStatusCode() === 429) {
84+
throw new GitLabRateLimitException();
85+
}
86+
87+
return $response;
88+
}
89+
}
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Internal\DLoad\Module\Repository\Internal\GitLab\Api;
6+
7+
use Internal\DLoad\Module\HttpClient\Factory as HttpFactory;
8+
use Internal\DLoad\Module\HttpClient\Method;
9+
use Internal\DLoad\Module\Repository\Internal\GitLab\Api\Response\ReleaseInfo;
10+
use Internal\DLoad\Module\Repository\Internal\GitLab\Api\Response\RepositoryInfo;
11+
use Internal\DLoad\Module\Repository\Internal\GitLab\Exception\GitLabRateLimitException;
12+
use Internal\DLoad\Module\Repository\Internal\Paginator;
13+
use Psr\Http\Client\ClientExceptionInterface;
14+
use Psr\Http\Message\ResponseInterface;
15+
use Psr\Http\Message\UriInterface;
16+
17+
/**
18+
* API client for specific GitLab repository operations.
19+
*
20+
* Bound to specific owner/repo pair and provides typed methods for GitLab API operations.
21+
*
22+
* @internal
23+
* @psalm-internal Internal\DLoad\Module\Repository\Internal\GitLab
24+
*/
25+
final class RepositoryApi
26+
{
27+
private const URL_REPOSITORY = 'https://gitlab.com/api/v4/projects/%s';
28+
private const URL_RELEASES = 'https://gitlab.com/api/v4/projects/%s/releases';
29+
private const URL_RELEASE_ASSET = 'https://gitlab.com/api/v4/projects/%s/releases/%s/downloads/%s';
30+
31+
/**
32+
* @var non-empty-string
33+
*/
34+
public readonly string $repositoryPath;
35+
36+
public function __construct(
37+
private readonly Client $client,
38+
private readonly HttpFactory $httpFactory,
39+
string $projectPath,
40+
) {
41+
$this->repositoryPath = $projectPath;
42+
}
43+
44+
/**
45+
* @param non-empty-string $repositoryPath
46+
* @param non-empty-string $releaseName
47+
* @param non-empty-string $fileName
48+
* @throws GitLabRateLimitException
49+
* @throws ClientExceptionInterface
50+
*/
51+
public function downloadArtifact(string $repositoryPath, string $releaseName, string $fileName): ResponseInterface
52+
{
53+
$url = \sprintf(self::URL_RELEASE_ASSET, \urlencode($repositoryPath), $releaseName, $fileName);
54+
return $this->client->downloadArtifact($url);
55+
}
56+
57+
/**
58+
* @param Method|non-empty-string $method
59+
* @param array<string, string> $headers
60+
* @throws GitLabRateLimitException
61+
* @throws ClientExceptionInterface
62+
*/
63+
public function request(Method|string $method, string|UriInterface $uri, array $headers = []): ResponseInterface
64+
{
65+
return $this->client->request($method, $uri, $headers);
66+
}
67+
68+
/**
69+
* @throws GitLabRateLimitException
70+
* @throws ClientExceptionInterface
71+
*/
72+
public function getRepository(): RepositoryInfo
73+
{
74+
$response = $this->request(Method::Get, \sprintf(self::URL_REPOSITORY, \urlencode($this->repositoryPath)));
75+
76+
/** @var array{
77+
* name: string,
78+
* name_with_namespace: string,
79+
* description: string|null,
80+
* web_url: string,
81+
* visibility: bool,
82+
* created_at: string,
83+
* updated_at: string
84+
* } $data */
85+
$data = \json_decode($response->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR);
86+
87+
return RepositoryInfo::fromApiResponse($data);
88+
}
89+
90+
/**
91+
* @param int<1, max> $page
92+
* @return Paginator<ReleaseInfo>
93+
* @throws GitLabRateLimitException
94+
* @throws ClientExceptionInterface
95+
*/
96+
public function getReleases(int $page = 1): Paginator
97+
{
98+
$pageLoader = function () use ($page): \Generator {
99+
$currentPage = $page;
100+
101+
do {
102+
try {
103+
$response = $this->releasesRequest($currentPage);
104+
105+
/** @var array<array-key, array{
106+
* name: non-empty-string|null,
107+
* tag_name: non-empty-string,
108+
* description: null|non-empty-string,
109+
* created_at: non-empty-string,
110+
* released_at: non-empty-string,
111+
* assets: array{
112+
* links: list<array{
113+
* name: non-empty-string,
114+
* url: non-empty-string,
115+
* direct_asset_url?: non-empty-string,
116+
* link_type: non-empty-string,
117+
* }>
118+
* },
119+
* upcoming_release: bool
120+
* }> $data */
121+
$data = \json_decode($response->getBody()->__toString(), true, 512, JSON_THROW_ON_ERROR);
122+
123+
// If empty response, no more pages
124+
if ($data === []) {
125+
return;
126+
}
127+
128+
$releases = [];
129+
foreach ($data as $releaseData) {
130+
try {
131+
$releases[] = ReleaseInfo::fromApiResponse($releaseData);
132+
} catch (\Throwable) {
133+
// Skip invalid releases
134+
continue;
135+
}
136+
}
137+
138+
yield $releases;
139+
140+
// Check if there are more pages
141+
$hasMorePages = $this->hasNextPage($response);
142+
$currentPage++;
143+
} catch (ClientExceptionInterface) {
144+
return;
145+
}
146+
} while ($hasMorePages);
147+
};
148+
149+
return Paginator::createFromGenerator($pageLoader(), null);
150+
}
151+
152+
/**
153+
* @param positive-int $page
154+
* @throws GitLabRateLimitException
155+
* @throws ClientExceptionInterface
156+
*/
157+
private function releasesRequest(int $page): ResponseInterface
158+
{
159+
return $this->request(
160+
Method::Get,
161+
$this->httpFactory->uri(
162+
\sprintf(self::URL_RELEASES, \urlencode($this->repositoryPath)),
163+
['page' => $page],
164+
),
165+
);
166+
}
167+
168+
private function hasNextPage(ResponseInterface $response): bool
169+
{
170+
$headers = $response->getHeaders();
171+
$link = $headers['link'] ?? [];
172+
173+
if (!isset($link[0])) {
174+
return false;
175+
}
176+
177+
return \str_contains($link[0], 'rel="next"');
178+
}
179+
}

0 commit comments

Comments
 (0)