diff --git a/CHANGELOG.md b/CHANGELOG.md index bcda30e8..dd2fecdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Add support for permanent project removal and project restoration * Add support for `regex` and `sort` in `Repositories::branches` * Add support for `Users::usersContributedProjects` +* Add support for additional filters and ordering options in `MergeRequests::all` ## [12.0.0] - 2025-02-23 diff --git a/src/Api/MergeRequests.php b/src/Api/MergeRequests.php index 83d5b7d0..8b9f3231 100644 --- a/src/Api/MergeRequests.php +++ b/src/Api/MergeRequests.php @@ -46,22 +46,43 @@ class MergeRequests extends AbstractApi public const STATE_LOCKED = 'locked'; /** - * @param array $parameters { + * @param array $parameters { * - * @var int[] $iids return the request having the given iid - * @var string $state return all merge requests or just those that are opened, closed, or - * merged - * @var string $scope Return merge requests for the given scope: created-by-me, - * assigned-to-me or all (default is created-by-me) - * @var string $order_by return requests ordered by created_at or updated_at fields (default is created_at) - * @var string $sort return requests sorted in asc or desc order (default is desc) - * @var string $milestone return merge requests for a specific milestone - * @var string $view if simple, returns the iid, URL, title, description, and basic state of merge request - * @var string $labels return merge requests matching a comma separated list of labels - * @var \DateTimeInterface $created_after return merge requests created after the given time (inclusive) - * @var \DateTimeInterface $created_before return merge requests created before the given time (inclusive) - * @var int $reviewer_id return merge requests which have the user as a reviewer with the given user id - * @var bool $wip return only draft merge requests (true) or only non-draft merge requests (false) + * @var int[] $iids return merge requests having the given IIDs + * @var array|string $approved_by_ids return merge requests approved by the given user IDs + * @var array|string $approved_by_usernames return merge requests approved by the given usernames + * @var array|string $approver_ids return merge requests with the given eligible approver IDs + * @var int|string $assignee_id return merge requests assigned to the given user ID, Any, or None + * @var string[] $assignee_username return merge requests assigned to the given usernames + * @var int $author_id return merge requests created by the given user ID + * @var string $author_username return merge requests created by the given username + * @var \DateTimeInterface $created_after return merge requests created on or after the given time + * @var \DateTimeInterface $created_before return merge requests created on or before the given time + * @var \DateTimeInterface $deployed_after return merge requests deployed after the given time + * @var \DateTimeInterface $deployed_before return merge requests deployed before the given time + * @var string $environment return merge requests deployed to the given environment + * @var string $in change the scope of the search attribute + * @var string $labels return merge requests matching a comma separated list of labels + * @var int $merge_user_id return merge requests merged by the given user ID + * @var string $merge_user_username return merge requests merged by the given username + * @var string $milestone return merge requests for a specific milestone + * @var string $my_reaction_emoji return merge requests reacted to by the authenticated user + * @var array $not return merge requests that do not match the supplied filters + * @var string $order_by return requests ordered by the given field + * @var int|string $reviewer_id return merge requests reviewed by the given user ID, Any, or None + * @var string $reviewer_username return merge requests reviewed by the given username + * @var string $scope return merge requests for the given scope + * @var string $search search merge requests by title and description + * @var string $sort return requests sorted in asc or desc order + * @var string $source_branch return merge requests with the given source branch + * @var string $state return merge requests with the given state + * @var string $target_branch return merge requests with the given target branch + * @var \DateTimeInterface $updated_after return merge requests updated on or after the given time + * @var \DateTimeInterface $updated_before return merge requests updated on or before the given time + * @var string $view if simple, returns a limited set of merge request fields + * @var bool $with_labels_details include label details in each merge request + * @var bool $with_merge_status_recheck request an asynchronous merge status recalculation + * @var bool $wip return only draft or only non-draft merge requests * } * * @throws UndefinedOptionsException if an option name is undefined @@ -75,29 +96,80 @@ public function all(int|string|null $project_id = null, array $parameters = []): return $utc->format('Y-m-d\TH:i:s.v\Z'); }; + $integerArrayValidator = function (array $value): bool { + return \count($value) === \count(\array_filter($value, 'is_int')); + }; + $stringArrayValidator = function (array $value): bool { + return \count($value) === \count(\array_filter($value, 'is_string')); + }; + $anyNoneValidator = function ($value): bool { + return \in_array($value, ['Any', 'None'], true); + }; + $idOrAnyNoneValidator = function ($value) use ($anyNoneValidator): bool { + return \is_int($value) || $anyNoneValidator($value); + }; + $idArrayOrAnyNoneValidator = function ($value) use ($idOrAnyNoneValidator, $anyNoneValidator): bool { + if (\is_string($value)) { + return $anyNoneValidator($value); + } + + if (!\is_array($value)) { + return false; + } + + return \count($value) === \count(\array_filter($value, $idOrAnyNoneValidator)); + }; + $stringOrStringArrayValidator = function ($value) use ($stringArrayValidator): bool { + if (\is_string($value)) { + return true; + } + + return \is_array($value) && $stringArrayValidator($value); + }; + $notFilterValidator = function (array $value): bool { + return [] === \array_diff(\array_keys($value), [ + 'labels', + 'milestone', + 'author_id', + 'author_username', + 'assignee_id', + 'assignee_username', + 'reviewer_id', + 'reviewer_username', + 'my_reaction_emoji', + ]); + }; + $resolver->setDefined('iids') ->setAllowedTypes('iids', 'array') - ->setAllowedValues('iids', function (array $value) { - return \count($value) === \count(\array_filter($value, 'is_int')); - }) + ->setAllowedValues('iids', $integerArrayValidator) ; - $resolver->setDefined('state') - ->setAllowedValues('state', [self::STATE_ALL, self::STATE_MERGED, self::STATE_OPENED, self::STATE_CLOSED, self::STATE_LOCKED]) + $resolver->setDefined('approved_by_ids') + ->setAllowedTypes('approved_by_ids', ['array', 'string']) + ->setAllowedValues('approved_by_ids', $idArrayOrAnyNoneValidator) ; - $resolver->setDefined('scope') - ->setAllowedValues('scope', ['created-by-me', 'assigned-to-me', 'all']) + $resolver->setDefined('approved_by_usernames') + ->setAllowedTypes('approved_by_usernames', ['array', 'string']) + ->setAllowedValues('approved_by_usernames', $stringOrStringArrayValidator) ; - $resolver->setDefined('order_by') - ->setAllowedValues('order_by', ['created_at', 'updated_at']) + $resolver->setDefined('approver_ids') + ->setAllowedTypes('approver_ids', ['array', 'string']) + ->setAllowedValues('approver_ids', $idArrayOrAnyNoneValidator) ; - $resolver->setDefined('sort') - ->setAllowedValues('sort', ['asc', 'desc']) + $resolver->setDefined('assignee_id') + ->setAllowedTypes('assignee_id', ['integer', 'string']) + ->setAllowedValues('assignee_id', $idOrAnyNoneValidator) ; - $resolver->setDefined('milestone'); - $resolver->setDefined('view') - ->setAllowedValues('view', ['simple']) + $resolver->setDefined('assignee_username') + ->setAllowedTypes('assignee_username', 'array') + ->setAllowedValues('assignee_username', $stringArrayValidator) + ; + $resolver->setDefined('author_id') + ->setAllowedTypes('author_id', 'integer') + ; + $resolver->setDefined('author_username') + ->setAllowedTypes('author_username', 'string') ; - $resolver->setDefined('labels'); $resolver->setDefined('created_after') ->setAllowedTypes('created_after', \DateTimeInterface::class) ->setNormalizer('created_after', $datetimeNormalizer) @@ -106,7 +178,60 @@ public function all(int|string|null $project_id = null, array $parameters = []): ->setAllowedTypes('created_before', \DateTimeInterface::class) ->setNormalizer('created_before', $datetimeNormalizer) ; - + $resolver->setDefined('deployed_after') + ->setAllowedTypes('deployed_after', \DateTimeInterface::class) + ->setNormalizer('deployed_after', $datetimeNormalizer) + ; + $resolver->setDefined('deployed_before') + ->setAllowedTypes('deployed_before', \DateTimeInterface::class) + ->setNormalizer('deployed_before', $datetimeNormalizer) + ; + $resolver->setDefined('environment') + ->setAllowedTypes('environment', 'string') + ; + $resolver->setDefined('in') + ->setAllowedValues('in', ['title', 'description', 'title,description', 'description,title']) + ; + $resolver->setDefined('labels'); + $resolver->setDefined('merge_user_id') + ->setAllowedTypes('merge_user_id', 'integer') + ; + $resolver->setDefined('merge_user_username') + ->setAllowedTypes('merge_user_username', 'string') + ; + $resolver->setDefined('milestone'); + $resolver->setDefined('my_reaction_emoji') + ->setAllowedTypes('my_reaction_emoji', 'string') + ; + $resolver->setDefined('non_archived') + ->setAllowedTypes('non_archived', 'bool') + ; + $resolver->setDefined('not') + ->setAllowedTypes('not', 'array') + ->setAllowedValues('not', $notFilterValidator) + ; + $resolver->setDefined('order_by') + ->setAllowedValues('order_by', ['created_at', 'updated_at', 'merged_at', 'label_priority', 'priority', 'milestone_due', 'popularity', 'title']) + ; + $resolver->setDefined('reviewer_id') + ->setAllowedTypes('reviewer_id', ['integer', 'string']) + ->setAllowedValues('reviewer_id', $idOrAnyNoneValidator) + ; + $resolver->setDefined('reviewer_username') + ->setAllowedTypes('reviewer_username', 'string') + ; + $resolver->setDefined('scope') + ->setAllowedValues('scope', ['created_by_me', 'assigned_to_me', 'reviews_for_me', 'all']) + ; + $resolver->setDefined('search'); + $resolver->setDefined('sort') + ->setAllowedValues('sort', ['asc', 'desc']) + ; + $resolver->setDefined('source_branch'); + $resolver->setDefined('state') + ->setAllowedValues('state', [self::STATE_ALL, self::STATE_MERGED, self::STATE_OPENED, self::STATE_CLOSED, self::STATE_LOCKED]) + ; + $resolver->setDefined('target_branch'); $resolver->setDefined('updated_after') ->setAllowedTypes('updated_after', \DateTimeInterface::class) ->setNormalizer('updated_after', $datetimeNormalizer) @@ -115,35 +240,21 @@ public function all(int|string|null $project_id = null, array $parameters = []): ->setAllowedTypes('updated_before', \DateTimeInterface::class) ->setNormalizer('updated_before', $datetimeNormalizer) ; - - $resolver->setDefined('scope') - ->setAllowedValues('scope', ['created_by_me', 'assigned_to_me', 'all']) + $resolver->setDefined('view') + ->setAllowedValues('view', ['simple']) + ; + $resolver->setDefined('with_labels_details') + ->setAllowedTypes('with_labels_details', 'bool') ; - $resolver->setDefined('author_id') - ->setAllowedTypes('author_id', 'integer'); - - $resolver->setDefined('assignee_id') - ->setAllowedTypes('assignee_id', 'integer'); - - $resolver->setDefined('search'); - $resolver->setDefined('source_branch'); - $resolver->setDefined('target_branch'); $resolver->setDefined('with_merge_status_recheck') ->setAllowedTypes('with_merge_status_recheck', 'bool') ; - $resolver->setDefined('approved_by_ids') - ->setAllowedTypes('approved_by_ids', 'array') - ->setAllowedValues('approved_by_ids', function (array $value) { - return \count($value) === \count(\array_filter($value, 'is_int')); - }) - ; - $resolver->setDefined('reviewer_id') - ->setAllowedTypes('reviewer_id', 'integer'); $resolver->setDefined('wip') ->setAllowedTypes('wip', 'boolean') ->addNormalizer('wip', static function ($resolver, $wip) { return $wip ? 'yes' : 'no'; - }); + }) + ; $path = null === $project_id ? 'merge_requests' : $this->getProjectPath($project_id, 'merge_requests'); diff --git a/tests/Api/MergeRequestsTest.php b/tests/Api/MergeRequestsTest.php index c0803014..0c3e30ca 100644 --- a/tests/Api/MergeRequestsTest.php +++ b/tests/Api/MergeRequestsTest.php @@ -60,18 +60,29 @@ public function shouldGetAllWithParams(): void ->with('projects/1/merge_requests', [ 'page' => 2, 'per_page' => 5, + 'approved_by_ids' => [1], + 'approver_ids' => [2], + 'assignee_id' => 1, + 'author_id' => 1, + 'environment' => 'production', + 'in' => 'title,description', 'labels' => 'label1,label2,label3', + 'merge_user_id' => 3, 'milestone' => 'milestone1', - 'order_by' => 'updated_at', - 'state' => 'all', - 'sort' => 'desc', - 'scope' => 'all', - 'author_id' => 1, - 'assignee_id' => 1, + 'my_reaction_emoji' => 'thumbsup', + 'non_archived' => true, + 'order_by' => 'merged_at', + 'reviewer_id' => 4, + 'scope' => 'reviews_for_me', + 'search' => 'search term', + 'sort' => 'asc', 'source_branch' => 'develop', + 'state' => 'all', 'target_branch' => 'master', + 'view' => 'simple', + 'with_labels_details' => true, 'with_merge_status_recheck' => true, - 'approved_by_ids' => [1], + 'wip' => 'yes', ]) ->willReturn($expectedArray) ; @@ -79,21 +90,85 @@ public function shouldGetAllWithParams(): void $this->assertEquals($expectedArray, $api->all(1, [ 'page' => 2, 'per_page' => 5, + 'approved_by_ids' => [1], + 'approver_ids' => [2], + 'assignee_id' => 1, + 'author_id' => 1, + 'environment' => 'production', + 'in' => 'title,description', 'labels' => 'label1,label2,label3', + 'merge_user_id' => 3, 'milestone' => 'milestone1', - 'order_by' => 'updated_at', - 'state' => 'all', - 'sort' => 'desc', - 'scope' => 'all', - 'author_id' => 1, - 'assignee_id' => 1, + 'my_reaction_emoji' => 'thumbsup', + 'non_archived' => true, + 'order_by' => 'merged_at', + 'reviewer_id' => 4, + 'scope' => 'reviews_for_me', + 'search' => 'search term', + 'sort' => 'asc', 'source_branch' => 'develop', + 'state' => 'all', 'target_branch' => 'master', + 'view' => 'simple', + 'with_labels_details' => true, 'with_merge_status_recheck' => true, - 'approved_by_ids' => [1], + 'wip' => true, ])); } + #[Test] + public function shouldGetAllWithUsernameAndNotParams(): void + { + $expectedArray = $this->getMultipleMergeRequestsData(); + $parameters = [ + 'approved_by_usernames' => ['alice', 'bob'], + 'assignee_username' => ['carol'], + 'author_username' => 'dan', + 'merge_user_username' => 'erin', + 'not' => [ + 'labels' => 'draft', + 'milestone' => 'Backlog', + 'author_username' => 'ignored-author', + 'assignee_username' => 'ignored-assignee', + 'reviewer_username' => 'ignored-reviewer', + 'my_reaction_emoji' => 'thumbsup', + ], + 'reviewer_username' => 'frank', + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/1/merge_requests', $parameters) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->all(1, $parameters)); + } + + #[Test] + public function shouldGetAllWithAnyAndNoneParams(): void + { + $expectedArray = $this->getMultipleMergeRequestsData(); + $parameters = [ + 'approved_by_ids' => ['Any'], + 'approver_ids' => ['None'], + 'assignee_id' => 'Any', + 'labels' => 'None', + 'milestone' => 'Any', + 'reviewer_id' => 'None', + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/1/merge_requests', $parameters) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->all(1, $parameters)); + } + #[Test] public function shouldGetAllWithDateTimeParams(): void { @@ -101,10 +176,18 @@ public function shouldGetAllWithDateTimeParams(): void $createdAfter = new \DateTime('2018-01-01 00:00:00'); $createdBefore = new \DateTime('2018-01-31 12:00:00.123+03:00'); + $deployedAfter = new \DateTime('2018-01-01 00:00:00'); + $deployedBefore = new \DateTime('2018-01-31 12:00:00.123+03:00'); + $updatedAfter = new \DateTime('2018-01-01 00:00:00'); + $updatedBefore = new \DateTime('2018-01-31 12:00:00.123+03:00'); $expectedWithArray = [ 'created_after' => '2018-01-01T00:00:00.000Z', 'created_before' => '2018-01-31T09:00:00.123Z', + 'deployed_after' => '2018-01-01T00:00:00.000Z', + 'deployed_before' => '2018-01-31T09:00:00.123Z', + 'updated_after' => '2018-01-01T00:00:00.000Z', + 'updated_before' => '2018-01-31T09:00:00.123Z', ]; $api = $this->getApiMock(); @@ -116,7 +199,14 @@ public function shouldGetAllWithDateTimeParams(): void $this->assertEquals( $expectedArray, - $api->all(1, ['created_after' => $createdAfter, 'created_before' => $createdBefore]) + $api->all(1, [ + 'created_after' => $createdAfter, + 'created_before' => $createdBefore, + 'deployed_after' => $deployedAfter, + 'deployed_before' => $deployedBefore, + 'updated_after' => $updatedAfter, + 'updated_before' => $updatedBefore, + ]) ); }