diff --git a/src/Cli/ModuleProvider.php b/src/Cli/ModuleProvider.php index 84cbad65..038e8569 100644 --- a/src/Cli/ModuleProvider.php +++ b/src/Cli/ModuleProvider.php @@ -13,6 +13,7 @@ use Horde\Cli\Modular\Module; use Horde\Cli\Modular\Modules; use Horde\Components\Module\Composer; +use Horde\Components\Module\ConventionalCommit; use Horde\Components\Module\Change; use Horde\Components\Module\InstallModule; use Horde\Components\Module\Package; @@ -48,6 +49,7 @@ public function getModules(): Modules $this->injector->get(ConfigModule::class), $this->injector->get(Composer::class), $this->injector->get(Git::class), + $this->injector->get(ConventionalCommit::class), $this->injector->get(Help::class), $this->injector->get(InstallModule::class), $this->injector->get(Package::class), diff --git a/src/ConventionalCommit.php b/src/ConventionalCommit.php index 32fb54fb..81fd71de 100755 --- a/src/ConventionalCommit.php +++ b/src/ConventionalCommit.php @@ -7,6 +7,7 @@ class ConventionalCommit extends GitCommit public readonly string $description; public readonly string $scope; public readonly string $type; + public readonly string $stability; public function __construct( string $commit = '', @@ -33,6 +34,7 @@ public function __construct( string $committer_date='', string $trailers='', array $conventionalAttributes=[], + string $stability = 'unchanged' ) { parent::__construct( commit: $commit, @@ -82,6 +84,11 @@ public function __construct( if ($severity === 'major') { $breaking = true; } + // Prefer stability from explicit parameter unless it's "unchanged" and conventionalAttributes disagrees. + if (array_key_exists('stability', $conventionalAttributes) && $stability === 'unchanged') { + $stability = $conventionalAttributes['stability']; + } + $this->stability = $stability; $this->breaking = $breaking; $this->severity = $severity; $this->scope = rtrim(ltrim((string)($conventionalAttributes['scope'] ?? ''), "("), ")"); @@ -94,9 +101,31 @@ public static function fromGitCommit(GitCommit $commit): ConventionalCommit|null { $regex = '/^(?Pbuild|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(?P\([\w\-\.]+\))?(?P!)?: (?P.*)\s*/u'; $res = preg_match($regex, $commit->subject, $matches); - if ($res == 0){ + if ($res == 0) { return null; } + // Handle BREAKING CHANGE: Description (from ConventionalCommit) and INCOMPATBLE: Description (from AutoSemVer) + $breakingFooterRegex = '/^\s*(?PBREAKING\s+CHANGE|INCOMPATIBLE):\s+(?P.+)/u'; + $bodyLines = explode("\n", $commit->body); + foreach ($bodyLines as $line) { + $res = preg_match($breakingFooterRegex, $line, $breakingMatches); + if (!empty($breakingMatches['breaking'])) { + $matches['breaking'] = '!'; + $matches['breaking_description'] = $breakingMatches['breaking_description']; + break; + } + } + // Handle AutoSemver inspired stability footer + $stabilityFooterRegex = '/^\s*(?PSTABILITY|STABILITY\s+CHANGE):\s+(?P.+)/u'; + $bodyLines = explode("\n", $commit->body); + foreach ($bodyLines as $line) { + $res = preg_match($stabilityFooterRegex, $line, $stabilityMatches); + if (!empty($stabilityMatches['new_stability'])) { + $matches['stability'] = $stabilityMatches['new_stability']; + break; + } + } + return new ConventionalCommit( conventionalAttributes: $matches, commit: $commit->commit, diff --git a/src/ConventionalCommitReader.php b/src/ConventionalCommitReader.php index 2f2473b0..59741b4f 100644 --- a/src/ConventionalCommitReader.php +++ b/src/ConventionalCommitReader.php @@ -15,6 +15,7 @@ final class ConventionalCommitReader { private GitCommitLog $log; private string $topSeverity = 'subpatch'; + private string $stability = 'unchanged'; public function __construct( GitCommitLog $log, ) { @@ -32,6 +33,13 @@ private function readConventionalCommits(GitCommitLog $log): void $conventionalCommits[] = $conventionalCommit; } } + // Get the latest stability-changing commit + foreach ($conventionalCommits as $commit) { + if ($commit->stability !== 'unchanged') { + $this->stability = $commit->stability; + } + break; + } // TODO: Handle trailers // Save only conventional commits $this->log = new GitCommitLog(...$conventionalCommits); @@ -54,6 +62,10 @@ public function getTopSeverity(): string { return $this->topSeverity; } + public function getLatestStabilityChange(): string + { + return $this->stability; + } public function getLog(): GitCommitLog { return $this->log; diff --git a/src/Dependencies/Injector.php b/src/Dependencies/Injector.php index 6a273d25..78870ed5 100644 --- a/src/Dependencies/Injector.php +++ b/src/Dependencies/Injector.php @@ -23,6 +23,7 @@ use Horde\Components\Release\Notes as ReleaseNotes; use Horde\Components\Release\Tasks as ReleaseTasks; use Horde\Components\Runner\Change as RunnerChange; +use Horde\Components\Runner\ConventionalCommit as RunnerConventionalCommit; use Horde\Components\Runner\CiPrebuild as RunnerCiPrebuild; use Horde\Components\Runner\CiSetup as RunnerCiSetup; use Horde\Components\Runner\Composer as RunnerComposer; @@ -251,6 +252,10 @@ public function getRunnerChange() { return $this->getInstance(RunnerChange::class); } + public function getRunnerConventionalCommit() + { + return $this->getInstance(RunnerConventionalCommit::class); + } /** * Returns the snapshot packaging handler for a package. diff --git a/src/Helper/Version.php b/src/Helper/Version.php index 555de450..fe154986 100644 --- a/src/Helper/Version.php +++ b/src/Helper/Version.php @@ -154,6 +154,7 @@ public function getOriginal(): string public function nextVersionObject(string $severity='patch', string $stability='unchanged'): Version { $nextVersion = clone $this; + $nextVersion->original = ''; $newStability = $this->normalizeStability($stability); $stabilityChangeDirection = $this->stabilityChangeDirection($newStability); // unstable target versions @@ -206,24 +207,41 @@ public function nextVersionObject(string $severity='patch', string $stability='u return $nextVersion; } - public static function isUnstable(): bool + public static function isUnstable(string $stability): bool { return $stability !== 'stable' && $stability !== ''; } - public static function isStable(): bool + public static function isStable(string $stability): bool { - return $this->stability === 'stable' || $this->stability === ''; + return $tability === 'stable' || $stability === ''; } - public static function isUp(string $stabilityChangeDirection): bool + public static function isUp(int $stabilityChangeDirection): bool { return $stabilityChangeDirection > 0; } - public static function isDown(string $stabilityChangeDirection): bool + public static function isDown(int $stabilityChangeDirection): bool { return $stabilityChangeDirection < 0; } + public function toHordeTag(): string + { + $version = 'v' . $this->getMajor() . '.' . $this->getMinor() . '.' . $this->getPatch(); + $subpatch = $this->getSubPatch(); + if ((int)$subpatch > 0) { + $version .= '.' . $subpatch; + } + $stability = $this->getStability(); + if ($stability) { + $version .= $stability . $this->getStabilityVersion(); + } + if ($this->getBuildInfo()) { + '+' . $this->getBuildInfo(); + } + return $version; + } + public function normalizeStability(string $stability): string { if ($stability === 'unchanged') { @@ -234,7 +252,7 @@ public function normalizeStability(string $stability): string $stability = ''; } elseif ($stability === 'unstable') { $stability = 'alpha'; - } elseif ($newStability === 'devel') { + } elseif ($stability === 'devel') { $stability = 'dev'; } return $stability; @@ -283,10 +301,10 @@ public static function fromComposerString(string $version): Version } // Parse stability and stability integer, stripping leading hyphen if any ltrim($stability, '-'); - $res = preg_match('/^(\w+)(\d+)?$/', $stability, $stabilityMatch); + $res = preg_match('/^([A-Za-z]+)(\d+)?$/', $stability, $stabilityMatch); $stability = $stabilityMatch[1] ?? ''; if ($stability) { - $stabilityVersion = $stabilityMatch[2] ?? 1; + $stabilityVersion = (int)$stabilityMatch[2] ?? 1; } else { $stabilityVersion = 0; } diff --git a/src/Module/ConventionalCommit.php b/src/Module/ConventionalCommit.php new file mode 100644 index 00000000..4e2fd2c6 --- /dev/null +++ b/src/Module/ConventionalCommit.php @@ -0,0 +1,141 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Components\Module; + +use Horde\Components\Config; +use Horde\Components\Dependencies; +use Horde\Components\Component\ComponentDirectory; +use Horde\Components\RuntimeContext\CurrentWorkingDirectory; + +/** + * Components_Module_Change:: records a change log entry. + * + * Copyright 2011-2024 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class ConventionalCommit extends Base +{ + public function __construct(Dependencies $dependencies) + { + parent::__construct($dependencies); + } + + + public function getOptionGroupTitle(): string + { + return 'Conventional Commit'; + } + + public function getOptionGroupDescription(): string + { + return 'Extracts changelog and version information from git logs in Conventional Commit format.'; + } + + public function getOptionGroupOptions(): array + { + return []; + } + + /** + * Get the usage title for this module. + * + * @return string The title. + */ + public function getTitle(): string + { + return 'conventionalcommit'; + } + + /** + * Get the usage description for this module. + * + * @return string The description. + */ + public function getUsage(): string + { + return "[show|lastversion|nextversion] [--since=tag] - Read from git log"; + } + + /** + * Return the action arguments supported by this module. + * + * @return array A list of supported action arguments. + */ + public function getActions(): array + { + return ['conventionalcommit']; + } + + /** + * Return the help text for the specified action. + * + * @param string $action The action. + * + * @return string The help text. + */ + public function getHelp($action): string + { + return " + conventionalcommit [show] [--since=tag] - Summary of changes. + conventionalcommit lastversion - Show the last version as of git tags. + conventionalcommit + conventionalcommit nextversion - Show the next version as of git tags."; + } + + /** + * Return the options that should be explained in the context help. + * + * @return array A list of option help texts. + */ + public function getContextOptionHelp(): array + { + return [ + //'--commit' => 'Commit the change log entries to git (using the change log entry as commit message).', '--pretend' => '' + // + ]; + } + + /** + * Determine if this module should act. Run all required actions if it has + * been instructed to do so. + * + * @param Config $config The configuration. + * + * @return bool True if the module performed some action. + */ + public function handle(Config $config): bool + { + $options = $config->getOptions(); + $arguments = $config->getArguments(); + + if (!empty($options['conventionalcommit']) || + (isset($arguments[0]) && $arguments[0] == 'conventionalcommit')) { + $componentDirectory = new ComponentDirectory($options['working_dir'] ?? new CurrentWorkingDirectory); + $component = $this->dependencies + ->getComponentFactory() + ->createSource($componentDirectory); + $config->setComponent($component); + + $this->dependencies->getRunnerConventionalCommit()->run($config); + return true; + } + return false; + } +} diff --git a/src/Runner/ConventionalCommit.php b/src/Runner/ConventionalCommit.php new file mode 100644 index 00000000..00514f3e --- /dev/null +++ b/src/Runner/ConventionalCommit.php @@ -0,0 +1,133 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +namespace Horde\Components\Runner; + +use Horde\Components\Config; +use Horde\Components\Helper\Commit as CommitHelper; +use Horde\Components\Output; +use Horde\Components\Helper\Git as GitHelper; +use Horde\Components\Helper\Version as VersionHelper; +use Horde\Components\ConventionalCommitReader; + +/** + * Components_Runner_Change:: adds a new change log entry. + * + * Copyright 2011-2024 Horde LLC (http://www.horde.org/) + * + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. + * + * @category Horde + * @package Components + * @author Gunnar Wrobel + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class ConventionalCommit +{ + + private VersionHelper $lastVersion; + private VersionHelper $nextVersion; + /** + * Constructor. + * + * @param Config $_config The configuration for the current job. + * @param Output $_output The output handler. + */ + public function __construct( + private readonly Config $_config, + /** + * The output handler. + * + * @param Output + */ + private readonly Output $_output + ) { + } + + public function loadCommitReader(): ConventionalCommitReader + { + // TODO: Externalize this later + $gitHelper = new GitHelper(); + // TODO: Don't rely on cwd, rely on component path + $gitLog = $gitHelper->getGitLog(getcwd()); + $originalTagString='0.0.1alpha1'; + foreach ($gitLog as $commit) { + if ($commit->hasTags()) { + $gitLog = $gitLog->getLogSince($commit); + $originalTagString = $commit->getTagName(); + break; + } + } + // Guard against tags that don't look like a version + $versionHelper = VersionHelper::fromComposerString($originalTagString); + $this->lastVersion = $versionHelper; + $conventional = new ConventionalCommitReader($gitLog); + $this->nextVersion = $this->lastVersion->nextVersionObject($conventional->getTopSeverity(), stability: $conventional->getLatestStabilityChange()); + return $conventional; + } + public function runShow(): void + { + $conventional = $this->loadCommitReader(); + $gitLog = $conventional->getLog(); + $this->_output->plain(sprintf("Found %d commits in Conventional Commits format since the last tag %s", count($gitLog), $this->lastVersion->toHordeTag())); + $this->_output->plain("see https://www.conventionalcommits.org/"); + $this->_output->plain(sprintf("Highest severity: %s\n", $conventional->getTopSeverity())); + $this->_output->plain("Anticipated next version tag: " . $this->nextVersion->toHordeTag()); + $this->_output->plain("Stability: " . $conventional->getLatestStabilityChange()); + foreach ($gitLog as $commit) { + $this->_output->plain(str_repeat("-", 79)); + $this->_output->plain(sprintf("%8s %8s %8s: %s", $commit->type, $commit->scope, $commit->severity, $commit->description)); + if ($commit->stability !== 'unchanged') { + $this->_output->plain("Stability: " . $commit->stability); + } + } + } + + public function runLastVersion(): void + { + $conventional = $this->loadCommitReader(); + $this->_output->plain($this->lastVersion->toHordeTag()); + } + public function runNextVersion(): void + { + $conventional = $this->loadCommitReader(); + $this->_output->plain($this->nextVersion->toHordeTag()); + } + + public function run(Config $config): void + { + $arguments = $this->_config->getArguments(); + $action = 'show'; + if (count($arguments) === 1 && $arguments[0] === 'conventionalcommit') { + $this->runShow(); + return; + } + if (count($arguments) > 1 && $arguments[0] === 'conventionalcommit') { + $action = $arguments[1]; + } + switch ($action) { + case 'show': + $this->runShow(); + break; + case 'lastversion': + $this->runLastVersion(); + break; + case 'nextversion': + $this->runNextVersion(); + break; + default: + throw new \InvalidArgumentException('Unknown action: ' . $action); + } + + } +}