diff --git a/src/Application/Console/Command/ReleasePlanCommand.php b/src/Application/Console/Command/ReleasePlanCommand.php index 8809a6e0..00b98e3a 100644 --- a/src/Application/Console/Command/ReleasePlanCommand.php +++ b/src/Application/Console/Command/ReleasePlanCommand.php @@ -4,12 +4,14 @@ namespace LibreCode\ReleaseTool\Application\Console\Command; -use DomainException; -use InvalidArgumentException; use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigLoader; use LibreCode\ReleaseTool\Application\Configuration\NoopConsumerConfigContextValidator; +use LibreCode\ReleaseTool\Application\Release\Exception\InvalidReleasePlanRequest; +use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanConfigurationFailure; +use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanRuleViolation; use LibreCode\ReleaseTool\Application\Release\ReleasePlanning; +use LibreCode\ReleaseTool\Application\Release\ReleasePlanUseCase; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -21,12 +23,15 @@ final class ReleasePlanCommand extends Command { public const int EXIT_NOT_READY = 3; + private readonly ReleasePlanUseCase $useCase; + public function __construct( - private readonly ReleasePlanning $planner, - private readonly ConsumerConfigLoader $configLoader = new ConsumerConfigLoader(), - private readonly ConsumerConfigContextValidator $contextValidator = new NoopConsumerConfigContextValidator(), + ReleasePlanning $planner, + ConsumerConfigLoader $configLoader = new ConsumerConfigLoader(), + ConsumerConfigContextValidator $contextValidator = new NoopConsumerConfigContextValidator(), private readonly ReleasePlanOutputPublisher $publisher = new ReleasePlanOutputPublisher(), ) { + $this->useCase = new ReleasePlanUseCase($planner, $configLoader, $contextValidator); parent::__construct(); } @@ -55,11 +60,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int { try { $request = ReleasePlanCommandRequest::fromInput($input); - $config = $this->configLoader->load($request->configPath); - $this->contextValidator->validate($config, $request->root); - $plan = $this->planner->plan($config, $request->planInput); + $plan = $this->useCase->execute($request->configPath, $request->root, $request->planInput); $this->publisher->publish($plan, $request->output, $output); - } catch (InvalidArgumentException|DomainException $exception) { + } catch (InvalidReleasePlanRequest|ReleasePlanConfigurationFailure|ReleasePlanRuleViolation $exception) { return $this->error($output, $input, $exception->getMessage()); } diff --git a/src/Application/Console/Command/ReleasePlanCommandRequest.php b/src/Application/Console/Command/ReleasePlanCommandRequest.php index af9f5f19..1782aa9f 100644 --- a/src/Application/Console/Command/ReleasePlanCommandRequest.php +++ b/src/Application/Console/Command/ReleasePlanCommandRequest.php @@ -4,7 +4,7 @@ namespace LibreCode\ReleaseTool\Application\Console\Command; -use InvalidArgumentException; +use LibreCode\ReleaseTool\Application\Release\Exception\InvalidReleasePlanRequest; use LibreCode\ReleaseTool\Application\Release\PlanReleaseInput; use LibreCode\ReleaseTool\Domain\Security\ReleaseMode; use LibreCode\ReleaseTool\Domain\Version\ReleaseChannel; @@ -43,7 +43,7 @@ private static function requiredString(InputInterface $input, string $name): str { $value = self::optionalString($input->getOption($name)); if ($value === null) { - throw new InvalidArgumentException(sprintf('--%s is required.', $name)); + throw new InvalidReleasePlanRequest(sprintf('--%s is required.', $name)); } return $value; @@ -63,7 +63,7 @@ private static function enumOption( $value = (string) $input->getOption($name); $resolved = $enum::tryFrom($value); if ($resolved === null) { - throw new InvalidArgumentException(sprintf('Invalid --%s; expected %s.', $name, $expected)); + throw new InvalidReleasePlanRequest(sprintf('Invalid --%s; expected %s.', $name, $expected)); } return $resolved; diff --git a/src/Application/Release/Exception/InvalidReleasePlanRequest.php b/src/Application/Release/Exception/InvalidReleasePlanRequest.php new file mode 100644 index 00000000..8061f119 --- /dev/null +++ b/src/Application/Release/Exception/InvalidReleasePlanRequest.php @@ -0,0 +1,9 @@ +configLoader->load($configPath); + $this->contextValidator->validate($config, $root); + } catch (InvalidArgumentException $exception) { + throw new ReleasePlanConfigurationFailure($exception->getMessage(), 0, $exception); + } + + return $this->planner->plan($config, $input); + } +} diff --git a/src/Application/Release/ReleasePlanner.php b/src/Application/Release/ReleasePlanner.php index 775eba59..9a25eea9 100644 --- a/src/Application/Release/ReleasePlanner.php +++ b/src/Application/Release/ReleasePlanner.php @@ -4,7 +4,7 @@ namespace LibreCode\ReleaseTool\Application\Release; -use DomainException; +use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanRuleViolation; use LibreCode\ReleaseTool\Application\Release\Port\GitHubRepository; use LibreCode\ReleaseTool\Application\Release\Port\GitRepository; use LibreCode\ReleaseTool\Application\Release\Port\ReleaseMetadataReader; @@ -42,7 +42,7 @@ public function plan(ConsumerConfig $config, PlanReleaseInput $input): ReleasePl $branchHead = $this->git->branchHead($input->branch); $baseSha = $input->ref !== null ? $this->git->resolve($input->ref) : $branchHead; if (!$this->git->isAncestor($baseSha, $branchHead)) { - throw new DomainException('Planning ref must be equal to or an ancestor of the selected branch head.'); + throw new ReleasePlanRuleViolation('Planning ref must be equal to or an ancestor of the selected branch head.'); } $repository = $this->repositoryIdentity($config); @@ -52,7 +52,7 @@ public function plan(ConsumerConfig $config, PlanReleaseInput $input): ReleasePl $previous = $this->git->previousRelease($baseSha, $config->tagPrefix, $config->initialRef); if (!$this->git->isAncestor($previous->sha, $baseSha)) { - throw new DomainException('Previous release baseline is not an ancestor of the planning base.'); + throw new ReleasePlanRuleViolation('Previous release baseline is not an ancestor of the planning base.'); } [$activity, $activityData, $warnings] = $this->releaseActivity( @@ -62,27 +62,23 @@ public function plan(ConsumerConfig $config, PlanReleaseInput $input): ReleasePl $baseSha, ); if (!$activity->hasReleasableActivity()) { - throw new DomainException('No releasable activity exists after the previous release.'); + throw new ReleasePlanRuleViolation('No releasable activity exists after the previous release.'); } $proposed = $this->proposedVersion($config, $metadata->version, $activity, $input); if ($proposed->major !== $appMajor) { - throw new DomainException('A release plan cannot change the app major implicitly.'); + throw new ReleasePlanRuleViolation('A release plan cannot change the app major implicitly.'); } $targetTag = $config->tagPrefix . (string) $proposed; if ($this->git->tagExists($targetTag) || $this->github->releaseExists($repository, $targetTag)) { - throw new DomainException(sprintf('Target tag or release already exists: %s', $targetTag)); + throw new ReleasePlanRuleViolation(sprintf('Target tag or release already exists: %s', $targetTag)); } $changelogTarget = str_replace('{major}', (string) $appMajor, $config->changelogPath); - try { - $this->git->readFile($baseSha, $changelogTarget); - } catch (\Throwable $exception) { - throw new DomainException( + if (!$this->git->fileExists($baseSha, $changelogTarget)) { + throw new ReleasePlanRuleViolation( sprintf('Configured changelog target does not exist at planning base: %s', $changelogTarget), - 0, - $exception, ); } @@ -181,12 +177,12 @@ private function repositoryIdentity(ConsumerConfig $config): string $repository = $config->repository ?? $gitRepository ?? $environmentRepository; if ($repository === null) { - throw new DomainException('Repository identity is unavailable; configure repository explicitly.'); + throw new ReleasePlanRuleViolation('Repository identity is unavailable; configure repository explicitly.'); } foreach ([$gitRepository, $environmentRepository] as $candidate) { if ($candidate !== null && strcasecmp($repository, $candidate) !== 0) { - throw new DomainException(sprintf( + throw new ReleasePlanRuleViolation(sprintf( 'Repository identity mismatch: configured %s, observed %s.', $repository, $candidate, @@ -383,8 +379,12 @@ private function proposedVersion( $value = substr($value, strlen($config->tagPrefix)); } - $override = Version::parse($value); - $this->versionTransitions->validateOverride($current, $override, $input->channel); + try { + $override = Version::parse($value); + $this->versionTransitions->validateOverride($current, $override, $input->channel); + } catch (\InvalidArgumentException $exception) { + throw new ReleasePlanRuleViolation($exception->getMessage(), 0, $exception); + } return $override; } diff --git a/src/Infrastructure/Git/LocalGitRepository.php b/src/Infrastructure/Git/LocalGitRepository.php index 45a966c4..4783d1e1 100644 --- a/src/Infrastructure/Git/LocalGitRepository.php +++ b/src/Infrastructure/Git/LocalGitRepository.php @@ -83,6 +83,20 @@ public function tagExists(string $tag): bool return $process->getExitCode() === 0; } + public function fileExists(string $sha, string $path): bool + { + $process = new Process(['git', 'ls-tree', '--name-only', $sha, '--', $path], $this->root); + $process->run(); + + if (!$process->isSuccessful()) { + throw new DomainException( + trim($process->getErrorOutput()) ?: sprintf('Unable to inspect file %s at %s.', $path, $sha), + ); + } + + return trim($process->getOutput()) !== ''; + } + public function readFile(string $sha, string $path): string { return $this->run(['git', 'show', $sha . ':' . $path]); diff --git a/tests/Acceptance/ReleasePlanCommandContractTest.php b/tests/Acceptance/ReleasePlanCommandContractTest.php new file mode 100644 index 00000000..65e0edb5 --- /dev/null +++ b/tests/Acceptance/ReleasePlanCommandContractTest.php @@ -0,0 +1,250 @@ +tester( + milestones: [new MilestoneInfo(150, '💚 Next Patch (35)', 'https://example.test/m150')], + ); + + $status = $tester->execute($this->arguments()); + + self::assertSame(0, $status); + + $plan = $this->json($tester); + self::assertSame(1, $plan['schema']); + self::assertSame('LibreSign/libresign', $plan['repository']); + self::assertSame('stable35', $plan['branch']); + self::assertSame('15.0.3', $plan['current_version']); + self::assertSame('15.0.4', $plan['proposed_version']); + self::assertTrue($plan['ready']); + self::assertSame(150, $plan['milestone']['number']); + self::assertSame('💚 Next Patch (35)', $plan['milestone']['title']); + self::assertSame([], $plan['backport_blockers']); + self::assertStringContainsString( + 'Matched decorated milestone title', + implode("\n", $plan['warnings']), + ); + } + + public function testMissingMilestoneKeepsPlanValidButNotReady(): void + { + $tester = $this->tester(milestones: []); + + $status = $tester->execute($this->arguments()); + + self::assertSame(ReleasePlanCommand::EXIT_NOT_READY, $status); + + $plan = $this->json($tester); + self::assertFalse($plan['ready']); + self::assertNull($plan['milestone']); + self::assertStringContainsString( + 'Expected open milestone not found', + implode("\n", $plan['warnings']), + ); + } + + public function testBackportBlockerRequiresExplicitOverride(): void + { + $backport = new PullRequestInfo( + 20, + '[stable35] backport: fix: pending fix', + '', + 'stable35', + null, + null, + 'https://example.test/20', + ['backport'], + 'contributor', + ); + + $blocked = $this->tester(open: [$backport]); + self::assertSame( + ReleasePlanCommand::EXIT_NOT_READY, + $blocked->execute($this->arguments()), + ); + + $blockedPlan = $this->json($blocked); + self::assertFalse($blockedPlan['ready']); + self::assertSame(20, $blockedPlan['backport_blockers'][0]['number']); + self::assertFalse($blockedPlan['ignore_open_backport']); + + $overridden = $this->tester(open: [$backport]); + self::assertSame( + 0, + $overridden->execute($this->arguments([ + '--ignore-open-backport' => true, + ])), + ); + + $overriddenPlan = $this->json($overridden); + self::assertTrue($overriddenPlan['ready']); + self::assertTrue($overriddenPlan['ignore_open_backport']); + self::assertSame(20, $overriddenPlan['backport_blockers'][0]['number']); + } + + public function testNoReleasableActivityRemainsAnInvalidPlanRequest(): void + { + $tester = $this->tester(closed: []); + + $status = $tester->execute($this->arguments()); + + self::assertSame(2, $status); + + $error = $this->json($tester); + self::assertSame(1, $error['schema']); + self::assertStringContainsString( + 'No releasable activity exists after the previous release.', + $error['error'], + ); + } + + public function testConfigurationFailureKeepsJsonErrorContract(): void + { + $tester = $this->tester(); + + $status = $tester->execute($this->arguments([ + '--config' => '/definitely/missing/release-config.yml', + ])); + + self::assertSame(2, $status); + + $error = $this->json($tester); + self::assertSame(1, $error['schema']); + self::assertStringContainsString('Configuration file not found', $error['error']); + } + + public function testRepositoryContextFailureKeepsJsonErrorContract(): void + { + $validator = new class implements ConsumerConfigContextValidator { + public function validate(ConsumerConfig $config, string $root): void + { + throw new InvalidArgumentException('Repository identity mismatch.'); + } + }; + $tester = $this->tester(contextValidator: $validator); + + $status = $tester->execute($this->arguments()); + + self::assertSame(2, $status); + + $error = $this->json($tester); + self::assertSame(1, $error['schema']); + self::assertSame('Repository identity mismatch.', $error['error']); + } + + /** + * @param list|null $closed + * @param list $open + * @param list|null $milestones + */ + private function tester( + ?array $closed = null, + array $open = [], + ?array $milestones = null, + ?ConsumerConfigContextValidator $contextValidator = null, + ): CommandTester { + $git = new InMemoryGitRepository( + 'LibreSign/libresign', + ['stable35' => self::HEAD], + [ + self::HEAD => [self::PREVIOUS, self::MERGE], + self::MERGE => [self::PREVIOUS], + self::PREVIOUS => [], + ], + new PreviousRelease('v15.0.3', self::PREVIOUS, 'v15.0.3'), + [ + self::HEAD . ':docs/changelogs/changelog-15.md' => "# Changelog\n", + ], + ); + + $github = new InMemoryGitHubRepository( + closed: $closed ?? [ + new PullRequestInfo( + 10, + 'fix: correct signature parsing', + '', + 'stable35', + self::MERGE, + '2026-09-20T00:00:00Z', + 'https://example.test/10', + [], + 'contributor', + ), + ], + open: $open, + milestones: $milestones ?? [ + new MilestoneInfo(7, 'Next Patch (35)', 'https://example.test/m7'), + ], + ); + + $planner = new ReleasePlanner( + $git, + $github, + new StaticMetadataReader(new ReleaseMetadata( + Version::parse('15.0.3'), + 35, + 35, + [], + )), + ); + + return new CommandTester(new ReleasePlanCommand( + $planner, + new ConsumerConfigLoader(), + $contextValidator ?? new NoopConsumerConfigContextValidator(), + )); + } + + /** @param array $overrides */ + private function arguments(array $overrides = []): array + { + return array_replace([ + '--branch' => 'stable35', + '--config' => dirname(__DIR__) . '/Fixtures/Configuration/libresign.yml', + '--json' => true, + ], $overrides); + } + + /** @return array */ + private function json(CommandTester $tester): array + { + return json_decode( + $tester->getDisplay(), + true, + flags: JSON_THROW_ON_ERROR, + ); + } +} diff --git a/tests/Fixtures/Configuration/libresign.yml b/tests/Fixtures/Configuration/libresign.yml index f025b9b1..5fb40b3b 100644 --- a/tests/Fixtures/Configuration/libresign.yml +++ b/tests/Fixtures/Configuration/libresign.yml @@ -4,7 +4,7 @@ app: id: libresign main_branch: main branches: - stable_pattern: '/^stable(?\d+)$/' + stable_pattern: '^stable(?\d+)$' version: source: appinfo/info.xml mirrors: diff --git a/tests/Fixtures/Release/InMemoryGitRepository.php b/tests/Fixtures/Release/InMemoryGitRepository.php index beadd812..0d2fcad9 100644 --- a/tests/Fixtures/Release/InMemoryGitRepository.php +++ b/tests/Fixtures/Release/InMemoryGitRepository.php @@ -63,6 +63,11 @@ public function tagExists(string $tag): bool return in_array($tag, $this->tags, true); } + public function fileExists(string $sha, string $path): bool + { + return isset($this->files[$sha . ':' . $path]); + } + public function readFile(string $sha, string $path): string { $key = $sha . ':' . $path; diff --git a/tests/Unit/Application/Configuration/ConsumerConfigLoaderTest.php b/tests/Unit/Application/Configuration/ConsumerConfigLoaderTest.php index 7e4f86c3..cd9d7bd1 100644 --- a/tests/Unit/Application/Configuration/ConsumerConfigLoaderTest.php +++ b/tests/Unit/Application/Configuration/ConsumerConfigLoaderTest.php @@ -16,7 +16,7 @@ public function testLoadsLibreSignConfigurationWithSeparateAppAndBranchConcepts( $config = (new ConsumerConfigLoader())->load($this->fixture('libresign.yml')); self::assertSame('libresign', $config->appId); - self::assertSame('/^stable(?\d+)$/', $config->stablePattern); + self::assertSame('^stable(?\\d+)$', $config->stablePattern); self::assertSame('appinfo/info.xml', $config->versionSource); self::assertSame(['package.json', 'package-lock.json'], $config->versionMirrors); self::assertSame('docs/changelogs/changelog-{major}.md', $config->changelogPath); diff --git a/tests/Unit/Application/Console/Command/ReleasePlanCommandRequestTest.php b/tests/Unit/Application/Console/Command/ReleasePlanCommandRequestTest.php index af746c8b..8b0ba100 100644 --- a/tests/Unit/Application/Console/Command/ReleasePlanCommandRequestTest.php +++ b/tests/Unit/Application/Console/Command/ReleasePlanCommandRequestTest.php @@ -4,8 +4,8 @@ namespace LibreCode\ReleaseTool\Tests\Unit\Application\Console\Command; -use InvalidArgumentException; use LibreCode\ReleaseTool\Application\Console\Command\ReleasePlanCommandRequest; +use LibreCode\ReleaseTool\Application\Release\Exception\InvalidReleasePlanRequest; use LibreCode\ReleaseTool\Domain\Security\ReleaseMode; use LibreCode\ReleaseTool\Domain\Version\ReleaseChannel; use PHPUnit\Framework\Attributes\DataProvider; @@ -54,7 +54,7 @@ public function testBuildsTypedRequestFromConsoleOptions(): void #[DataProvider('invalidOptionProvider')] public function testRejectsInvalidOptions(array $options, string $message): void { - $this->expectException(InvalidArgumentException::class); + $this->expectException(InvalidReleasePlanRequest::class); $this->expectExceptionMessage($message); ReleasePlanCommandRequest::fromInput($this->input($options)); diff --git a/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php b/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php index 42542fc3..e8b58e92 100644 --- a/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php +++ b/tests/Unit/Application/Console/Command/ReleasePlanCommandTest.php @@ -5,99 +5,28 @@ namespace LibreCode\ReleaseTool\Tests\Unit\Application\Console\Command; use DomainException; -use InvalidArgumentException; +use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigLoader; use LibreCode\ReleaseTool\Application\Configuration\NoopConsumerConfigContextValidator; use LibreCode\ReleaseTool\Application\Console\Command\ReleasePlanCommand; -use LibreCode\ReleaseTool\Application\Console\Command\ReleasePlanCommandRequest; use LibreCode\ReleaseTool\Application\Release\ReleasePlanning; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Tester\CommandTester; final class ReleasePlanCommandTest extends TestCase { - #[DataProvider('invalidInputProvider')] - public function testRequestRejectsInvalidInput(array $options, string $message): void - { - $input = $this->createMock(InputInterface::class); - $defaults = [ - 'config' => '.nextcloud-release.yml', - 'root' => '.', - 'branch' => 'stable35', - 'ref' => null, - 'release-version' => null, - 'channel' => 'final', - 'mode' => 'normal', - 'safe-public-text' => null, - 'ignore-open-backport' => false, - 'create-follow-up-milestone' => false, - 'json' => false, - 'output-file' => null, - 'github-output' => null, - 'github-step-summary' => null, - 'github-annotations' => false, - 'tool-version' => 'test', - ]; - $values = array_replace($defaults, $options); - $input->method('getOption')->willReturnCallback( - static fn (string $name): mixed => $values[$name] ?? null, - ); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage($message); - - ReleasePlanCommandRequest::fromInput($input); - } - - /** @return iterable,string}> */ - public static function invalidInputProvider(): iterable - { - yield 'missing branch' => [ - ['branch' => ''], - '--branch is required.', - ]; - yield 'invalid channel' => [ - ['channel' => 'nightly'], - 'Invalid --channel; expected alpha, beta, rc or final.', - ]; - yield 'invalid mode' => [ - ['mode' => 'unsafe'], - 'Invalid --mode; expected normal or security.', - ]; - } - - public function testExpectedDomainFailureIsRenderedAsInvalidCommand(): void - { - $planner = $this->createMock(ReleasePlanning::class); - $planner->method('plan')->willThrowException(new DomainException('Release cannot be planned.')); - - $tester = new CommandTester(new ReleasePlanCommand( - $planner, - contextValidator: new NoopConsumerConfigContextValidator(), - )); - - $status = $tester->execute([ - '--branch' => 'stable35', - '--config' => dirname(__DIR__, 4) . '/Fixtures/Configuration/libresign.yml', - ]); - - self::assertSame(2, $status); - self::assertStringContainsString('Release cannot be planned.', $tester->getDisplay()); - } - - public function testUnexpectedFailureIsNotMaskedAsUserError(): void + public function testUnexpectedDomainFailureIsNotMaskedAsUserError(): void { $planner = $this->createMock(ReleasePlanning::class); - $planner->method('plan')->willThrowException(new \RuntimeException('Unexpected infrastructure failure.')); + $planner->method('plan')->willThrowException(new DomainException('Unexpected Git failure.')); $tester = new CommandTester(new ReleasePlanCommand( $planner, - contextValidator: new NoopConsumerConfigContextValidator(), + new ConsumerConfigLoader(), + new NoopConsumerConfigContextValidator(), )); - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Unexpected infrastructure failure.'); + $this->expectException(DomainException::class); + $this->expectExceptionMessage('Unexpected Git failure.'); $tester->execute([ '--branch' => 'stable35',