From 6b1ddf915f7a6171ad8d545fc5c8074496d31cea Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Wed, 6 May 2026 16:15:57 +0100 Subject: [PATCH] Add container registry API --- CHANGELOG.md | 1 + src/Api/Groups.php | 7 ++ src/Api/Projects.php | 26 +++++++ src/Api/Registry.php | 121 ++++++++++++++++++++++++++++++ src/Client.php | 6 ++ tests/Api/GroupsTest.php | 36 +++++++++ tests/Api/ProjectsTest.php | 51 +++++++++++++ tests/Api/RegistryTest.php | 150 +++++++++++++++++++++++++++++++++++++ 8 files changed, 398 insertions(+) create mode 100644 src/Api/Registry.php create mode 100644 tests/Api/RegistryTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 664822c8..e38ab0f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Add support for merge request resource label event endpoints * Add support for merge request and merge request note award emoji endpoints * Add support for `MergeRequests::remove` +* Add support for container registry endpoints * Add support for `Environments::stopStale` * Add support for `last_activity_after` and `last_activity_before` in `Groups::projects` * Fix `Projects::pipelines` date filters to include time information diff --git a/src/Api/Groups.php b/src/Api/Groups.php index b2131c36..b8ac7b48 100644 --- a/src/Api/Groups.php +++ b/src/Api/Groups.php @@ -733,6 +733,13 @@ public function packages(int|string $group_id, array $parameters = []): mixed return $this->get('groups/'.self::encodePath($group_id).'/packages', $resolver->resolve($parameters)); } + public function registryRepositories(int|string $group_id, array $parameters = []): mixed + { + $resolver = $this->createOptionsResolver(); + + return $this->get('groups/'.self::encodePath($group_id).'/registry/repositories', $resolver->resolve($parameters)); + } + private function getGroupSearchResolver(): OptionsResolver { $resolver = $this->getSubgroupSearchResolver(); diff --git a/src/Api/Projects.php b/src/Api/Projects.php index 8c1e3ed0..430f4b23 100644 --- a/src/Api/Projects.php +++ b/src/Api/Projects.php @@ -1345,6 +1345,32 @@ public function removeJobTokenScopeAllowlistGroup(int|string $project_id, int $t return $this->delete($this->getProjectPath($project_id, 'job_token_scope/groups_allowlist/'.self::encodePath($target_group_id))); } + /** + * @param array $parameters { + * + * @var bool $tags include an array of tags in each repository + * @var bool $tags_count include tags_count in each repository + * } + */ + public function registryRepositories(int|string $project_id, array $parameters = []): mixed + { + $resolver = $this->createOptionsResolver(); + $booleanNormalizer = function (Options $resolver, $value): string { + return $value ? 'true' : 'false'; + }; + + $resolver->setDefined('tags') + ->setAllowedTypes('tags', 'bool') + ->setNormalizer('tags', $booleanNormalizer) + ; + $resolver->setDefined('tags_count') + ->setAllowedTypes('tags_count', 'bool') + ->setNormalizer('tags_count', $booleanNormalizer) + ; + + return $this->get($this->getProjectPath($project_id, 'registry/repositories'), $resolver->resolve($parameters)); + } + public function protectedTags(int|string $project_id): mixed { return $this->get('projects/'.self::encodePath($project_id).'/protected_tags'); diff --git a/src/Api/Registry.php b/src/Api/Registry.php new file mode 100644 index 00000000..074ff811 --- /dev/null +++ b/src/Api/Registry.php @@ -0,0 +1,121 @@ + + * (c) Graham Campbell + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gitlab\Api; + +use Symfony\Component\OptionsResolver\Options; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class Registry extends AbstractApi +{ + /** + * @param array $parameters { + * + * @var bool $tags include an array of tags in the response + * @var bool $tags_count include tags_count in the response + * @var bool $size include the deduplicated size in the response + * } + */ + public function repository(int|string $repository_id, array $parameters = []): mixed + { + $resolver = self::createRepositoryResolver(); + + return $this->get('registry/repositories/'.self::encodePath($repository_id), $resolver->resolve($parameters)); + } + + public function removeRepository(int|string $project_id, int $repository_id): mixed + { + return $this->delete($this->getProjectPath($project_id, 'registry/repositories/'.self::encodePath($repository_id))); + } + + public function repositoryTags(int|string $project_id, int $repository_id, array $parameters = []): mixed + { + $resolver = $this->createOptionsResolver(); + + return $this->get( + $this->getProjectPath($project_id, 'registry/repositories/'.self::encodePath($repository_id).'/tags'), + $resolver->resolve($parameters) + ); + } + + public function repositoryTag(int|string $project_id, int $repository_id, string $tag_name): mixed + { + return $this->get($this->getProjectPath( + $project_id, + 'registry/repositories/'.self::encodePath($repository_id).'/tags/'.self::encodePath($tag_name) + )); + } + + public function removeRepositoryTag(int|string $project_id, int $repository_id, string $tag_name): mixed + { + return $this->delete($this->getProjectPath( + $project_id, + 'registry/repositories/'.self::encodePath($repository_id).'/tags/'.self::encodePath($tag_name) + )); + } + + /** + * @param array $parameters { + * + * @var string $name_regex_delete regex of tag names to delete + * @var string $name_regex_keep regex of tag names to keep + * @var int $keep_n number of latest matching tags to keep + * @var string $older_than delete tags older than this human-readable duration + * } + */ + public function removeRepositoryTags(int|string $project_id, int $repository_id, array $parameters): mixed + { + $resolver = new OptionsResolver(); + $resolver->setRequired('name_regex_delete') + ->setAllowedTypes('name_regex_delete', 'string') + ; + $resolver->setDefined('name_regex_keep') + ->setAllowedTypes('name_regex_keep', 'string') + ; + $resolver->setDefined('keep_n') + ->setAllowedTypes('keep_n', 'int') + ; + $resolver->setDefined('older_than') + ->setAllowedTypes('older_than', 'string') + ; + + return $this->delete( + $this->getProjectPath($project_id, 'registry/repositories/'.self::encodePath($repository_id).'/tags'), + $resolver->resolve($parameters) + ); + } + + private static function createRepositoryResolver(): OptionsResolver + { + $resolver = new OptionsResolver(); + $booleanNormalizer = function (Options $resolver, $value): string { + return $value ? 'true' : 'false'; + }; + + $resolver->setDefined('tags') + ->setAllowedTypes('tags', 'bool') + ->setNormalizer('tags', $booleanNormalizer) + ; + $resolver->setDefined('tags_count') + ->setAllowedTypes('tags_count', 'bool') + ->setNormalizer('tags_count', $booleanNormalizer) + ; + $resolver->setDefined('size') + ->setAllowedTypes('size', 'bool') + ->setNormalizer('size', $booleanNormalizer) + ; + + return $resolver; + } +} diff --git a/src/Client.php b/src/Client.php index 51da66b1..dfdc02d7 100644 --- a/src/Client.php +++ b/src/Client.php @@ -33,6 +33,7 @@ use Gitlab\Api\PersonalAccessTokens; use Gitlab\Api\ProjectNamespaces; use Gitlab\Api\Projects; +use Gitlab\Api\Registry; use Gitlab\Api\Repositories; use Gitlab\Api\RepositoryFiles; use Gitlab\Api\ResourceIterationEvents; @@ -251,6 +252,11 @@ public function projects(): Projects return new Projects($this); } + public function registry(): Registry + { + return new Registry($this); + } + public function repositories(): Repositories { return new Repositories($this); diff --git a/tests/Api/GroupsTest.php b/tests/Api/GroupsTest.php index 6fb9d140..85c45950 100644 --- a/tests/Api/GroupsTest.php +++ b/tests/Api/GroupsTest.php @@ -771,6 +771,42 @@ public function shouldGetPackages(): void $this->assertEquals($expectedArray, $api->packages(1)); } + #[Test] + public function shouldGetGroupRegistryRepositories(): void + { + $expectedArray = [ + ['id' => 1, 'name' => 'A registry'], + ['id' => 2, 'name' => 'Another registry'], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('groups/1/registry/repositories', []) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->registryRepositories(1)); + } + + #[Test] + public function shouldGetGroupRegistryRepositoriesWithPagination(): void + { + $expectedArray = [ + ['id' => 1, 'name' => 'A registry'], + ['id' => 2, 'name' => 'Another registry'], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('groups/1/registry/repositories', ['page' => 2, 'per_page' => 15]) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->registryRepositories(1, ['page' => 2, 'per_page' => 15])); + } + #[Test] public function shouldGetGroupMergeRequests(): void { diff --git a/tests/Api/ProjectsTest.php b/tests/Api/ProjectsTest.php index d58161ae..935a490e 100644 --- a/tests/Api/ProjectsTest.php +++ b/tests/Api/ProjectsTest.php @@ -3000,6 +3000,57 @@ public function shouldRemoveJobTokenScopeAllowlistGroup(): void $this->assertEquals($expectedString, $api->removeJobTokenScopeAllowlistGroup(1, 42)); } + #[Test] + public function shouldGetProjectRegistryRepositories(): void + { + $expectedArray = [ + ['id' => 1, 'name' => 'A registry'], + ['id' => 2, 'name' => 'Another registry'], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/123/registry/repositories', []) + ->willReturn($expectedArray); + + $this->assertEquals($expectedArray, $api->registryRepositories(123)); + } + + #[Test] + public function shouldGetProjectRegistryRepositoriesWithTags(): void + { + $expectedArray = [ + ['id' => 1, 'name' => 'A registry', 'tags' => ['1.0', '1.1'], 'tags_count' => 2], + ['id' => 2, 'name' => 'Another registry', 'tags' => ['2.0', '2.1'], 'tags_count' => 2], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/123/registry/repositories', ['tags' => 'true', 'tags_count' => 'true']) + ->willReturn($expectedArray); + + $this->assertEquals($expectedArray, $api->registryRepositories(123, ['tags' => true, 'tags_count' => true])); + } + + #[Test] + public function shouldGetProjectRegistryRepositoriesWithPagination(): void + { + $expectedArray = [ + ['id' => 1, 'name' => 'A registry'], + ['id' => 2, 'name' => 'Another registry'], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/123/registry/repositories', ['page' => 2, 'per_page' => 15]) + ->willReturn($expectedArray); + + $this->assertEquals($expectedArray, $api->registryRepositories(123, ['page' => 2, 'per_page' => 15])); + } + #[Test] public function shouldUploadAvatar(): void { diff --git a/tests/Api/RegistryTest.php b/tests/Api/RegistryTest.php new file mode 100644 index 00000000..4f8688a3 --- /dev/null +++ b/tests/Api/RegistryTest.php @@ -0,0 +1,150 @@ + + * (c) Graham Campbell + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Gitlab\Tests\Api; + +use Gitlab\Api\Registry; +use PHPUnit\Framework\Attributes\Test; + +final class RegistryTest extends TestCase +{ + #[Test] + public function shouldGetRepository(): void + { + $expectedArray = ['id' => 1, 'name' => 'A registry']; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('registry/repositories/1', []) + ->willReturn($expectedArray); + + $this->assertEquals($expectedArray, $api->repository(1)); + } + + #[Test] + public function shouldGetRepositoryWithParams(): void + { + $expectedArray = ['id' => 1, 'name' => 'A registry', 'tags' => ['tag1', 'tag2'], 'tags_count' => 2, 'size' => 12345]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('registry/repositories/1', ['tags' => 'true', 'tags_count' => 'true', 'size' => 'true']) + ->willReturn($expectedArray); + + $this->assertEquals($expectedArray, $api->repository(1, ['tags' => true, 'tags_count' => true, 'size' => true])); + } + + #[Test] + public function shouldRemoveRepository(): void + { + $expectedString = ''; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('delete') + ->with('projects/1/registry/repositories/2') + ->willReturn($expectedString); + + $this->assertEquals($expectedString, $api->removeRepository(1, 2)); + } + + #[Test] + public function shouldGetRepositoryTags(): void + { + $expectedArray = [ + ['name' => 'v1.0.0', 'path' => 'group/project:v1.0.0'], + ['name' => 'v1.1.0', 'path' => 'group/project:v1.1.0'], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/1/registry/repositories/2/tags', []) + ->willReturn($expectedArray); + + $this->assertEquals($expectedArray, $api->repositoryTags(1, 2)); + } + + #[Test] + public function shouldGetRepositoryTagsWithPagination(): void + { + $expectedArray = [ + ['name' => 'v1.0.0', 'path' => 'group/project:v1.0.0'], + ['name' => 'v1.1.0', 'path' => 'group/project:v1.1.0'], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/1/registry/repositories/2/tags', ['page' => 2, 'per_page' => 15]) + ->willReturn($expectedArray); + + $this->assertEquals($expectedArray, $api->repositoryTags(1, 2, ['page' => 2, 'per_page' => 15])); + } + + #[Test] + public function shouldGetRepositoryTag(): void + { + $expectedArray = ['name' => 'v1.0.0', 'path' => 'group/project:v1.0.0']; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/1/registry/repositories/2/tags/v1.0.0') + ->willReturn($expectedArray); + + $this->assertEquals($expectedArray, $api->repositoryTag(1, 2, 'v1.0.0')); + } + + #[Test] + public function shouldRemoveRepositoryTag(): void + { + $expectedString = ''; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('delete') + ->with('projects/1/registry/repositories/2/tags/v1.0.0') + ->willReturn($expectedString); + + $this->assertEquals($expectedString, $api->removeRepositoryTag(1, 2, 'v1.0.0')); + } + + #[Test] + public function shouldRemoveRepositoryTags(): void + { + $expectedString = ''; + $parameters = [ + 'name_regex_delete' => '.*', + 'name_regex_keep' => 'stable.*', + 'keep_n' => 5, + 'older_than' => '2d', + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('delete') + ->with('projects/1/registry/repositories/2/tags', $parameters) + ->willReturn($expectedString); + + $this->assertEquals($expectedString, $api->removeRepositoryTags(1, 2, $parameters)); + } + + protected function getApiClass(): string + { + return Registry::class; + } +}