diff --git a/CHANGELOG.md b/CHANGELOG.md index 19718d96..6f39d234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Add support for personal access tokens * Add support for project integrations endpoints * Add support for `Projects::updateDeployKey` +* Add support for project push rules * Add support for group hook endpoints * Add support for `job_inputs` and `job_variables_attributes` in `Jobs::play` * Add support for `inputs` in `Projects::createPipeline` diff --git a/src/Api/Projects.php b/src/Api/Projects.php index 157f86af..4a3dcc35 100644 --- a/src/Api/Projects.php +++ b/src/Api/Projects.php @@ -699,6 +699,68 @@ public function deleteDeployToken(int|string $project_id, int $token_id): mixed return $this->delete($this->getProjectPath($project_id, 'deploy_tokens/'.self::encodePath($token_id))); } + public function pushRule(int|string $project_id): mixed + { + return $this->get($this->getProjectPath($project_id, 'push_rule')); + } + + /** + * @param array $parameters { + * + * @var string $author_email_regex all commit author emails must match this regular expression + * @var string $branch_name_regex all branch names must match this regular expression + * @var bool $commit_committer_check only allow commits when the committer email is one of the user's verified emails + * @var bool $commit_committer_name_check only allow commits when the author name matches the user's GitLab account name + * @var string $commit_message_negative_regex reject commit messages matching this regular expression + * @var string $commit_message_regex require commit messages to match this regular expression + * @var bool $deny_delete_tag deny deleting tags + * @var string $file_name_regex reject committed filenames matching this regular expression + * @var int $max_file_size maximum file size in MB + * @var bool $member_check restrict commit authors by email to existing GitLab users + * @var bool $prevent_secrets reject files likely to contain secrets + * @var bool $reject_non_dco_commits reject commits that are not DCO certified + * @var bool $reject_unsigned_commits reject unsigned commits + * } + * + * @throws UndefinedOptionsException If an option name is undefined + * @throws InvalidOptionsException If an option doesn't fulfill the specified validation rules + */ + public function createPushRule(int|string $project_id, array $parameters = []): mixed + { + return $this->post($this->getProjectPath($project_id, 'push_rule'), self::createPushRuleOptionsResolver()->resolve($parameters)); + } + + /** + * @param array $parameters { + * + * @var string $author_email_regex all commit author emails must match this regular expression + * @var string $branch_name_regex all branch names must match this regular expression + * @var bool $commit_committer_check only allow commits when the committer email is one of the user's verified emails + * @var bool $commit_committer_name_check only allow commits when the author name matches the user's GitLab account name + * @var string $commit_message_negative_regex reject commit messages matching this regular expression + * @var string $commit_message_regex require commit messages to match this regular expression + * @var bool $deny_delete_tag deny deleting tags + * @var string $file_name_regex reject committed filenames matching this regular expression + * @var int $max_file_size maximum file size in MB + * @var bool $member_check restrict commit authors by email to existing GitLab users + * @var bool $prevent_secrets reject files likely to contain secrets + * @var bool $reject_non_dco_commits reject commits that are not DCO certified + * @var bool $reject_unsigned_commits reject unsigned commits + * } + * + * @throws UndefinedOptionsException If an option name is undefined + * @throws InvalidOptionsException If an option doesn't fulfill the specified validation rules + */ + public function updatePushRule(int|string $project_id, array $parameters = []): mixed + { + return $this->put($this->getProjectPath($project_id, 'push_rule'), self::createPushRuleOptionsResolver()->resolve($parameters)); + } + + public function deletePushRule(int|string $project_id): mixed + { + return $this->delete($this->getProjectPath($project_id, 'push_rule')); + } + /** * @param array $parameters { * @@ -1503,4 +1565,41 @@ public function search(int|string $id, array $parameters = []): mixed return $this->get('projects/'.self::encodePath($id).'/search', $resolver->resolve($parameters)); } + + private static function createPushRuleOptionsResolver(): OptionsResolver + { + $resolver = new OptionsResolver(); + + foreach ([ + 'author_email_regex', + 'branch_name_regex', + 'commit_message_negative_regex', + 'commit_message_regex', + 'file_name_regex', + ] as $option) { + $resolver->setDefined($option) + ->setAllowedTypes($option, 'string') + ; + } + + foreach ([ + 'commit_committer_check', + 'commit_committer_name_check', + 'deny_delete_tag', + 'member_check', + 'prevent_secrets', + 'reject_non_dco_commits', + 'reject_unsigned_commits', + ] as $option) { + $resolver->setDefined($option) + ->setAllowedTypes($option, 'bool') + ; + } + + $resolver->setDefined('max_file_size') + ->setAllowedTypes('max_file_size', 'int') + ; + + return $resolver; + } } diff --git a/tests/Api/ProjectsTest.php b/tests/Api/ProjectsTest.php index 60950208..f341613b 100644 --- a/tests/Api/ProjectsTest.php +++ b/tests/Api/ProjectsTest.php @@ -19,6 +19,8 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; +use Symfony\Component\OptionsResolver\Exception\UndefinedOptionsException; class ProjectsTest extends TestCase { @@ -1600,6 +1602,186 @@ public function shouldDeleteDeployToken(): void $this->assertEquals($expectedBool, $api->deleteDeployToken(1, 2)); } + #[Test] + public function shouldGetPushRule(): void + { + $expectedArray = [ + 'id' => 1, + 'project_id' => 3, + 'commit_message_regex' => 'Fixes \\d+\\..*', + 'branch_name_regex' => 'feature\\/.*', + 'author_email_regex' => '@example.com$', + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/3/push_rule') + ->willReturn($expectedArray); + + $this->assertEquals($expectedArray, $api->pushRule(3)); + } + + #[Test] + public function shouldGetUnsetPushRule(): void + { + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/3/push_rule') + ->willReturn('null'); + + $this->assertEquals('null', $api->pushRule(3)); + } + + #[Test] + public function shouldCreatePushRule(): void + { + $expectedBool = true; + $parameters = [ + 'commit_message_regex' => 'Fixes \\d+\\..*', + 'branch_name_regex' => 'feature\\/.*', + 'author_email_regex' => '@example.com$', + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('post') + ->with('projects/3/push_rule', $parameters) + ->willReturn($expectedBool); + + $this->assertEquals($expectedBool, $api->createPushRule(3, $parameters)); + } + + #[Test] + public function shouldCreatePushRuleWithBooleanAndIntegerParameters(): void + { + $expectedBool = true; + $parameters = [ + 'deny_delete_tag' => false, + 'member_check' => true, + 'prevent_secrets' => true, + 'commit_committer_check' => false, + 'commit_committer_name_check' => true, + 'reject_unsigned_commits' => false, + 'reject_non_dco_commits' => true, + 'max_file_size' => 100, + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('post') + ->with('projects/3/push_rule', $parameters) + ->willReturn($expectedBool); + + $this->assertEquals($expectedBool, $api->createPushRule(3, $parameters)); + } + + #[Test] + public function shouldUpdatePushRule(): void + { + $expectedBool = true; + $parameters = [ + 'commit_message_regex' => 'Fixes \\d+\\..*', + 'branch_name_regex' => 'feature\\/.*', + 'author_email_regex' => '@example.com$', + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('put') + ->with('projects/3/push_rule', $parameters) + ->willReturn($expectedBool); + + $this->assertEquals($expectedBool, $api->updatePushRule(3, $parameters)); + } + + #[Test] + public function shouldUpdatePushRuleWithBooleanAndIntegerParameters(): void + { + $expectedBool = true; + $parameters = [ + 'deny_delete_tag' => true, + 'member_check' => false, + 'prevent_secrets' => false, + 'commit_committer_check' => true, + 'commit_committer_name_check' => false, + 'reject_unsigned_commits' => true, + 'reject_non_dco_commits' => false, + 'max_file_size' => 25, + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('put') + ->with('projects/3/push_rule', $parameters) + ->willReturn($expectedBool); + + $this->assertEquals($expectedBool, $api->updatePushRule(3, $parameters)); + } + + #[Test] + public function shouldDeletePushRule(): void + { + $expectedBool = true; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('delete') + ->with('projects/3/push_rule') + ->willReturn($expectedBool); + + $this->assertEquals($expectedBool, $api->deletePushRule(3)); + } + + #[Test] + public function shouldRejectUndefinedParameterWhenCreatingPushRule(): void + { + $api = $this->getApiMock(); + $api->expects($this->never()) + ->method('post'); + + $this->expectException(UndefinedOptionsException::class); + + $api->createPushRule(3, ['unsupported_parameter' => true]); + } + + #[Test] + public function shouldRejectUndefinedParameterWhenUpdatingPushRule(): void + { + $api = $this->getApiMock(); + $api->expects($this->never()) + ->method('put'); + + $this->expectException(UndefinedOptionsException::class); + + $api->updatePushRule(3, ['unsupported_parameter' => true]); + } + + #[Test] + public function shouldRequireIntegerMaxFileSizeWhenCreatingPushRule(): void + { + $api = $this->getApiMock(); + $api->expects($this->never()) + ->method('post'); + + $this->expectException(InvalidOptionsException::class); + + $api->createPushRule(3, ['max_file_size' => '100']); + } + + #[Test] + public function shouldRequireIntegerMaxFileSizeWhenUpdatingPushRule(): void + { + $api = $this->getApiMock(); + $api->expects($this->never()) + ->method('put'); + + $this->expectException(InvalidOptionsException::class); + + $api->updatePushRule(3, ['max_file_size' => '100']); + } + #[Test] public function shouldGetEvents(): void {