Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
49c1d06
refactor: add typed release planning failures
vitormattos Sep 30, 2026
a4acb10
refactor: add typed release planning failures
vitormattos Sep 30, 2026
8e2ef48
refactor: add typed release planning failures
vitormattos Sep 30, 2026
112051b
refactor: add typed release planning failures
vitormattos Sep 30, 2026
a75ed7e
refactor: use typed release plan request errors
vitormattos Sep 30, 2026
76cc929
refactor: isolate release plan use case orchestration
vitormattos Sep 30, 2026
958b4be
refactor: keep release plan use case independent from console
vitormattos Sep 30, 2026
023ba90
refactor: expose explicit git file existence check
vitormattos Sep 30, 2026
c011e2c
refactor: distinguish missing files from git failures
vitormattos Sep 30, 2026
80ddff2
test: support explicit git file existence checks
vitormattos Sep 30, 2026
840dd6d
refactor: use typed planning violations and explicit file checks
vitormattos Sep 30, 2026
93a6f79
refactor: make release plan command depend on typed use case
vitormattos Sep 30, 2026
1e47dce
refactor: type explicit version planning errors
vitormattos Sep 30, 2026
8629016
refactor: compose release plan use case in application factory
vitormattos Sep 30, 2026
4659963
test: use typed release planning failures in command tests
vitormattos Sep 30, 2026
85e43c7
test: classify release plan failures with data provider
vitormattos Sep 30, 2026
164e2e7
chore: update release plan branch with main
vitormattos Sep 30, 2026
1600d18
test: keep request validation coverage focused
vitormattos Sep 30, 2026
4682b4a
test: preserve release plan failure boundary
vitormattos Sep 30, 2026
3baf61c
test: cover release plan CLI business contract
vitormattos Sep 30, 2026
e36874d
refactor: preserve release plan exception contracts
vitormattos Sep 30, 2026
4c2fb2f
refactor: preserve release plan exception contracts
vitormattos Sep 30, 2026
2eca3bd
refactor: preserve release plan exception contracts
vitormattos Sep 30, 2026
94bee1d
refactor: preserve release plan exception contracts
vitormattos Sep 30, 2026
04c692c
refactor: preserve release plan command constructor
vitormattos Sep 30, 2026
41fe0ac
refactor: keep application factory release plan contract
vitormattos Sep 30, 2026
c397890
test: keep release plan command contract compatible
vitormattos Sep 30, 2026
fc97c77
test: exercise preserved release plan command API
vitormattos Sep 30, 2026
690a621
test: align LibreSign config fixture with consumer contract
vitormattos Sep 30, 2026
cd537fb
test: repair LibreSign configuration fixture
vitormattos Sep 30, 2026
4f45598
refactor: keep release plan exception inheritance
vitormattos Sep 30, 2026
5bc95b3
refactor: keep release plan exception inheritance
vitormattos Sep 30, 2026
57150cd
refactor: keep release plan exception inheritance
vitormattos Sep 30, 2026
09918f2
refactor: catch explicit release plan failures
vitormattos Sep 30, 2026
7876e09
refactor: remove unnecessary release failure marker
vitormattos Sep 30, 2026
ddf6ac7
fix: import explicit release plan failures
vitormattos Sep 30, 2026
7962dfe
test: align stable pattern expectation
vitormattos Sep 30, 2026
af03d1f
test: expect delimiter-free stable pattern
vitormattos Sep 30, 2026
5da5686
test: repair consumer config loader test
vitormattos Sep 30, 2026
282d7f2
test: cover configuration failures through CLI contract
vitormattos Sep 30, 2026
40fab4e
test: prefer release plan behavior coverage
vitormattos Sep 30, 2026
6c646ca
test: keep only unexpected failure boundary unit test
vitormattos Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions src/Application/Console/Command/ReleasePlanCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
}

Expand Down Expand Up @@ -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());
}

Expand Down
6 changes: 3 additions & 3 deletions src/Application/Console/Command/ReleasePlanCommandRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace LibreCode\ReleaseTool\Application\Release\Exception;

final class InvalidReleasePlanRequest extends \InvalidArgumentException
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace LibreCode\ReleaseTool\Application\Release\Exception;

final class ReleasePlanConfigurationFailure extends \InvalidArgumentException
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

declare(strict_types=1);

namespace LibreCode\ReleaseTool\Application\Release\Exception;

final class ReleasePlanRuleViolation extends \DomainException
{
}
2 changes: 2 additions & 0 deletions src/Application/Release/Port/GitRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public function isAncestor(string $ancestorSha, string $descendantSha): bool;

public function tagExists(string $tag): bool;

public function fileExists(string $sha, string $path): bool;

public function readFile(string $sha, string $path): string;

public function commitDate(string $sha): string;
Expand Down
33 changes: 33 additions & 0 deletions src/Application/Release/ReleasePlanUseCase.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace LibreCode\ReleaseTool\Application\Release;

use InvalidArgumentException;
use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigContextValidator;
use LibreCode\ReleaseTool\Application\Configuration\ConsumerConfigLoader;
use LibreCode\ReleaseTool\Application\Release\Exception\ReleasePlanConfigurationFailure;
use LibreCode\ReleaseTool\Domain\Release\ReleasePlan;

final readonly class ReleasePlanUseCase
{
public function __construct(
private ReleasePlanning $planner,
private ConsumerConfigLoader $configLoader,
private ConsumerConfigContextValidator $contextValidator,
) {
}

public function execute(string $configPath, string $root, PlanReleaseInput $input): ReleasePlan
{
try {
$config = $this->configLoader->load($configPath);
$this->contextValidator->validate($config, $root);
} catch (InvalidArgumentException $exception) {
throw new ReleasePlanConfigurationFailure($exception->getMessage(), 0, $exception);
}

return $this->planner->plan($config, $input);
}
}
32 changes: 16 additions & 16 deletions src/Application/Release/ReleasePlanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -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,
);
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down
14 changes: 14 additions & 0 deletions src/Infrastructure/Git/LocalGitRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
Loading
Loading