From ff2f5a92678ce48e7b498c68e1732a2810f776e2 Mon Sep 17 00:00:00 2001 From: Graham Campbell Date: Wed, 6 May 2026 18:09:48 +0100 Subject: [PATCH] Add merge request dependency endpoints Support GitLab's merge request dependency API while keeping list pagination delegated to ResultPager. Co-authored-by: Kayw <29700073+kayw-geek@users.noreply.github.com> --- CHANGELOG.md | 1 + src/Api/MergeRequests.php | 52 +++++++++++ tests/Api/MergeRequestsTest.php | 150 ++++++++++++++++++++++++++++++++ 3 files changed, 203 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 306d4569..19718d96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Add support for project CI/CD job token scope endpoints * Add support for merge request resource label event endpoints * Add support for merge request and merge request note award emoji endpoints +* Add support for merge request dependency endpoints * Add support for `MergeRequests::remove` * Add support for `MergeRequests::addToMergeTrain` * Correct merge request API parameter handling diff --git a/src/Api/MergeRequests.php b/src/Api/MergeRequests.php index c1edefaa..92af1a3a 100644 --- a/src/Api/MergeRequests.php +++ b/src/Api/MergeRequests.php @@ -939,6 +939,58 @@ public function deleteLevelRule(int|string $project_id, int $mr_iid, int $approv return $this->delete($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/approval_rules/'.self::encodePath($approval_rule_id))); } + public function dependencies(int|string $project_id, int $mr_iid): mixed + { + return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/blocks')); + } + + public function showDependency(int|string $project_id, int $mr_iid, int $block_id): mixed + { + return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/blocks/'.self::encodePath($block_id))); + } + + /** + * @param array $parameters { + * + * @var int $blocking_merge_request_id global ID of the blocking merge request + * @var int $blocking_merge_request_iid IID of the blocking merge request + * @var int|string $blocking_project_id project containing the blocking merge request + * } + */ + public function createDependency(int|string $project_id, int $mr_iid, array $parameters): mixed + { + $resolver = new OptionsResolver(); + $resolver->setDefined('blocking_merge_request_id') + ->setAllowedTypes('blocking_merge_request_id', 'int') + ; + $resolver->setDefined('blocking_merge_request_iid') + ->setAllowedTypes('blocking_merge_request_iid', 'int') + ; + $resolver->setDefined('blocking_project_id') + ->setAllowedTypes('blocking_project_id', ['int', 'string']) + ; + + $parameters = $resolver->resolve($parameters); + $hasBlockingId = isset($parameters['blocking_merge_request_id']); + $hasBlockingIid = isset($parameters['blocking_merge_request_iid']); + + if ($hasBlockingId === $hasBlockingIid) { + throw new InvalidOptionsException('Exactly one of "blocking_merge_request_id" or "blocking_merge_request_iid" must be provided.'); + } + + return $this->post($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/blocks'), $parameters); + } + + public function deleteDependency(int|string $project_id, int $mr_iid, int $block_id): mixed + { + return $this->delete($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/blocks/'.self::encodePath($block_id))); + } + + public function blockedMergeRequests(int|string $project_id, int $mr_iid): mixed + { + return $this->get($this->getProjectPath($project_id, 'merge_requests/'.self::encodePath($mr_iid).'/blockees')); + } + private static function isIntegerArray(array $value): bool { return \count($value) === \count(\array_filter($value, 'is_int')); diff --git a/tests/Api/MergeRequestsTest.php b/tests/Api/MergeRequestsTest.php index feb42804..f2228281 100644 --- a/tests/Api/MergeRequestsTest.php +++ b/tests/Api/MergeRequestsTest.php @@ -1328,6 +1328,156 @@ public function shoudDeleteLevelRule(): void $this->assertEquals($expectedValue, $api->deleteLevelRule(1, 2, 3)); } + #[Test] + public function shouldGetDependencies(): void + { + $expectedArray = [ + ['id' => 1, 'project_id' => 1, 'blocking_merge_request' => ['id' => 3], 'blocked_merge_request' => ['id' => 2]], + ['id' => 2, 'project_id' => 1, 'blocking_merge_request' => ['id' => 4], 'blocked_merge_request' => ['id' => 2]], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/1/merge_requests/2/blocks') + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->dependencies(1, 2)); + } + + #[Test] + public function shouldGetDependenciesForStringProjectPath(): void + { + $expectedArray = [ + ['id' => 1, 'project_id' => 1, 'blocking_merge_request' => ['id' => 3], 'blocked_merge_request' => ['id' => 2]], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/group%2Fproject/merge_requests/2/blocks') + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->dependencies('group/project', 2)); + } + + #[Test] + public function shouldShowDependency(): void + { + $expectedArray = ['id' => 3, 'project_id' => 1, 'blocking_merge_request' => ['id' => 4], 'blocked_merge_request' => ['id' => 2]]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/1/merge_requests/2/blocks/3') + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->showDependency(1, 2, 3)); + } + + #[Test] + public function shouldCreateDependencyWithBlockingMergeRequestId(): void + { + $expectedArray = ['id' => 1, 'project_id' => 1, 'blocking_merge_request' => ['id' => 3], 'blocked_merge_request' => ['id' => 2]]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('post') + ->with('projects/1/merge_requests/2/blocks', ['blocking_merge_request_id' => 3]) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->createDependency(1, 2, ['blocking_merge_request_id' => 3])); + } + + #[Test] + public function shouldCreateDependencyWithBlockingMergeRequestIid(): void + { + $expectedArray = ['id' => 1, 'project_id' => 1, 'blocking_merge_request' => ['iid' => 3], 'blocked_merge_request' => ['iid' => 2]]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('post') + ->with('projects/1/merge_requests/2/blocks', ['blocking_merge_request_iid' => 3]) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->createDependency(1, 2, ['blocking_merge_request_iid' => 3])); + } + + #[Test] + public function shouldCreateDependencyWithBlockingProjectId(): void + { + $expectedArray = ['id' => 1, 'project_id' => 1, 'blocking_merge_request' => ['iid' => 3], 'blocked_merge_request' => ['iid' => 2]]; + $parameters = ['blocking_merge_request_iid' => 3, 'blocking_project_id' => 'group/project']; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('post') + ->with('projects/1/merge_requests/2/blocks', $parameters) + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->createDependency(1, 2, $parameters)); + } + + #[Test] + public function shouldRejectDependencyWithoutBlockingMergeRequest(): void + { + $this->expectException(\Symfony\Component\OptionsResolver\Exception\InvalidOptionsException::class); + $this->expectExceptionMessage('Exactly one of "blocking_merge_request_id" or "blocking_merge_request_iid" must be provided.'); + + $this->getApiMock()->createDependency(1, 2, []); + } + + #[Test] + public function shouldRejectDependencyWithMultipleBlockingMergeRequests(): void + { + $this->expectException(\Symfony\Component\OptionsResolver\Exception\InvalidOptionsException::class); + $this->expectExceptionMessage('Exactly one of "blocking_merge_request_id" or "blocking_merge_request_iid" must be provided.'); + + $this->getApiMock()->createDependency(1, 2, [ + 'blocking_merge_request_id' => 3, + 'blocking_merge_request_iid' => 4, + ]); + } + + #[Test] + public function shouldDeleteDependency(): void + { + $expectedValue = true; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('delete') + ->with('projects/1/merge_requests/2/blocks/3') + ->willReturn($expectedValue) + ; + + $this->assertEquals($expectedValue, $api->deleteDependency(1, 2, 3)); + } + + #[Test] + public function shouldGetBlockedMergeRequests(): void + { + $expectedArray = [ + ['id' => 1, 'project_id' => 1, 'blocking_merge_request' => ['id' => 2], 'blocked_merge_request' => ['id' => 3]], + ['id' => 2, 'project_id' => 1, 'blocking_merge_request' => ['id' => 2], 'blocked_merge_request' => ['id' => 4]], + ]; + + $api = $this->getApiMock(); + $api->expects($this->once()) + ->method('get') + ->with('projects/1/merge_requests/2/blockees') + ->willReturn($expectedArray) + ; + + $this->assertEquals($expectedArray, $api->blockedMergeRequests(1, 2)); + } + protected function getMultipleMergeRequestsData(): array { return [