diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index a714e3e4..4f6aeb30 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -23,6 +23,12 @@ '@PHP8x2Migration' => true, 'php_unit_test_class_requires_covers' => true, 'nullable_type_declaration_for_default_null_value' => true, + 'fully_qualified_strict_types' => true, + 'global_namespace_import' => [ + 'import_classes' => true, + 'import_constants' => true, + 'import_functions' => true, + ], 'Horde/remove_php_version_comment' => true, 'Horde/update_copyright_year' => true, ]) diff --git a/composer.json b/composer.json index 9aba8da6..63e9c2fb 100644 --- a/composer.json +++ b/composer.json @@ -43,19 +43,18 @@ "horde/eventdispatcher": "* || dev-FRAMEWORK_6_0", "horde/githubapiclient": "* || dev-FRAMEWORK_6_0", "horde/composer": "* || dev-FRAMEWORK_6_0", - "horde/phpconfigfile": "^0.0.1-alpha4 || dev-FRAMEWORK_6_0" + "horde/phpconfigfile": "^0.0.1-alpha5 || dev-FRAMEWORK_6_0", + "horde/core": "* || dev-FRAMEWORK_6_0" }, "require-dev": { - "horde/test": "^3 || dev-FRAMEWORK_6_0", - "phpunit/phpunit": "^12 || ^11 || ^10 || ^9" + "horde/test": "^3 || dev-FRAMEWORK_6_0" }, "suggest": { "horde/test": "^3 || dev-FRAMEWORK_6_0", "squizlabs/php_codesniffer": "^3.5", "pear/archive_tar": "^1.4", "phploc/phploc": "^7", - "phpmd/phpmd": "^2.9", - "phpunit/phpunit": "^9" + "phpmd/phpmd": "^2.9" }, "autoload": { "psr-0": { @@ -83,5 +82,6 @@ "branch-alias": { "dev-FRAMEWORK_6_0": "1.x-dev" } - } -} \ No newline at end of file + }, + "minimum-stability": "dev" +} diff --git a/config/conf.php.dist b/config/conf.php.dist index 684af8ff..d29b1f6e 100644 --- a/config/conf.php.dist +++ b/config/conf.php.dist @@ -12,18 +12,15 @@ * PHP Version 7 */ -/* PEAR server name. Only change for testing purposes. */ -$conf['releaseserver'] = 'pear.horde.org'; - -/* PEAR server directory. Only change for testing purposes. */ -$conf['releasedir'] = '/horde/web/pear.horde.org'; - -/* Username for horde.org. - * Make sure you belong to the "horde" group there. */ -$conf['horde_user'] = ''; - -/* Needed to update the Whups versions */ -$conf['horde_pass'] = ''; +/** + * Whups (bugs.horde.org) Credentials + * + * Username and password for updating bugs.horde.org (the Horde bug tracker). + * Required for the Bugs release task which updates Whups versions. + * Make sure you belong to the "horde" group on bugs.horde.org. + */ +$conf['whups.user'] = ''; +$conf['whups.pass'] = ''; /* From: address for announcements. */ $conf['from'] = 'Full name '; @@ -53,11 +50,16 @@ $conf['from'] = 'Full name '; */ $conf['github.token'] = ''; -/* Path to a checkout of the horde-web repository. */ -$conf['web_dir'] = '/var/www/horde-web'; +/** + * Horde Web Repository Directory + * + * Path to a checkout of the horde-web repository (www.horde.org website). + * This directory contains the SQLite database for version tracking. + */ +$conf['hordeweb.dir'] = '/var/www/horde-web'; // Target directory for a developer installation. The webroot will be the ./web dir under that install dir -$conf['install_base'] = '/var/www/horde-dev'; +$conf['install.dir'] = '/var/www/horde-dev'; /** * Vendor: Used by the composer, packagist, satis and git-related tasks @@ -66,37 +68,51 @@ $conf['install_base'] = '/var/www/horde-dev'; // $conf['vendor'] = 'horde'; /** - * Git Repo Base: The base URL for your remote git repo - * Defaults to https://github.com/$vendor if unset + * SCM Repository Base URL + * + * The base URL for cloning remote git repositories. + * Used to construct clone URLs: scm.repo.base + ComponentName.git + * + * Defaults to https://github.com/{vendor}/ if unset * If you configure this, $vendor will NOT be appended + * + * Examples: + * https://github.com/horde/ (default for vendor=horde) + * https://git.mycompany.com/horde/ (custom git server) */ -//$conf['git_repo_base'] = 'https://git.mycompany.com/level/nextlevel/'; +//$conf['scm.repo.base'] = 'https://git.mycompany.com/horde/'; -/** composer_repo - Type of loader hints to generate - * Default is not to generate any hints. Dependencies will be from packagist +/** + * Composer Repository Type + * + * Type of repository hints to generate in composer.json files + * Default is not to generate any hints. Dependencies will be from packagist. + * + * Options: + * 'vcs' - Generate a VCS repo source per horde internal dependency + * 'satis:https://satis.url' - Generate a satis source for all horde namespace */ -// generate a vcs repo source per horde internal dependency -// $conf['composer_repo'] = 'vcs'; -// generate a satis source for all horde namespace -// $conf['composer_repo'] = 'satis:https://satis.horde.org'; +// $conf['composer.repo'] = 'vcs'; +// $conf['composer.repo'] = 'satis:https://satis.horde.org'; /** - * composer_version - tweak all dependency versions - * to a common branch name or version + * Composer Version Override + * + * Tweak all dependency versions to a common branch name or version. * This is used by the horde-components composer command and - * the composer release task unless overwritten there + * the composer release task unless overwritten there. * * Default: Depend on versions from .horde.yml + * + * Examples: + * 'dev-master' - Depend on master branch + * 'dev-FRAMEWORK_6_0' - Depend on FRAMEWORK_6_0 branch + * 'dev-staging' - Depend on a staging branch */ -// depend on master branch -// $conf['composer_version'] = 'dev-master'; -// depend on a staging branch - components won't check if it even exists! -// $conf['composer_version'] = 'dev-staging'; - -$conf['composer_version'] = 'dev-FRAMEWORK_6_0'; +$conf['composer.version'] = 'dev-FRAMEWORK_6_0'; /* Well known composer native substitutes for pear dependencies */ -$conf['composer_opts']['pear-substitutes'] = [ +$conf['composer.opts']['pear-substitutes'] = [ ['pear.php.net/Archive_Tar' => ['source' => 'Packagist', 'name' => 'pear/archive_tar']], ['pear.php.net/Console_Color2' => diff --git a/data/scaffolding/library/src/Example.php b/data/scaffolding/library/src/Example.php index 4f6f2dae..6ada11c2 100644 --- a/data/scaffolding/library/src/Example.php +++ b/data/scaffolding/library/src/Example.php @@ -1,4 +1,5 @@ argv); + return new ArrayIterator($this->argv); } public static function fromGlobal() diff --git a/src/Auth/AuthenticationFactory.php b/src/Auth/AuthenticationFactory.php index 7fe978c8..c0020942 100644 --- a/src/Auth/AuthenticationFactory.php +++ b/src/Auth/AuthenticationFactory.php @@ -9,6 +9,7 @@ use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use RuntimeException; +use InvalidArgumentException; /** * Authentication factory that determines which authentication method to use @@ -99,7 +100,7 @@ private function createGitHubAppStrategy(): ?AuthenticationStrategyInterface return null; } - $jwtGenerator = new Rs256JwtGenerator(); + $jwtGenerator = new GitHubJwtGenerator(); $authService = new GitHubAppAuthenticationService( $appConfig, $jwtGenerator, @@ -123,7 +124,7 @@ private function loadGitHubAppConfig(): ?GitHubAppConfig // Try environment variables first try { return GitHubAppConfig::fromEnvironment(); - } catch (\InvalidArgumentException $e) { + } catch (InvalidArgumentException $e) { // Environment variables not set or incomplete, try config file } @@ -153,7 +154,7 @@ private function loadGitHubAppConfig(): ?GitHubAppConfig installationId: (int) $installationId, privateKeyPath: (string) $privateKeyPath ); - } catch (\InvalidArgumentException $e) { + } catch (InvalidArgumentException $e) { // Invalid configuration, return null return null; } diff --git a/src/Auth/AuthenticationStrategyInterface.php b/src/Auth/AuthenticationStrategyInterface.php index 701dd26f..f6cfafc2 100644 --- a/src/Auth/AuthenticationStrategyInterface.php +++ b/src/Auth/AuthenticationStrategyInterface.php @@ -5,6 +5,7 @@ namespace Horde\Components\Auth; use Horde\GithubApiClient\GithubApiClient; +use RuntimeException; /** * Interface for GitHub authentication strategies @@ -24,7 +25,7 @@ interface AuthenticationStrategyInterface * Authenticate and return configured GitHub API client * * @return GithubApiClient Configured client ready to make API calls - * @throws \RuntimeException If authentication fails + * @throws RuntimeException If authentication fails */ public function authenticate(): GithubApiClient; diff --git a/src/Auth/GitHubAppAuthenticationService.php b/src/Auth/GitHubAppAuthenticationService.php index 47bb3e18..77ee18d8 100644 --- a/src/Auth/GitHubAppAuthenticationService.php +++ b/src/Auth/GitHubAppAuthenticationService.php @@ -12,6 +12,7 @@ use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; use RuntimeException; +use Exception; /** * GitHub App authentication service @@ -160,7 +161,7 @@ private function generateInstallationAccessToken(): InstallationAccessToken $this->config->installationId, new CreateInstallationAccessTokenParams() ); - } catch (\Exception $e) { + } catch (Exception $e) { throw new RuntimeException( "Failed to create installation access token: {$e->getMessage()}", 0, diff --git a/src/Auth/GitHubAppAuthenticationStrategy.php b/src/Auth/GitHubAppAuthenticationStrategy.php index 73740093..a6a285fa 100644 --- a/src/Auth/GitHubAppAuthenticationStrategy.php +++ b/src/Auth/GitHubAppAuthenticationStrategy.php @@ -6,6 +6,7 @@ use Horde\GithubApiClient\GithubApiClient; use RuntimeException; +use Exception; /** * GitHub App authentication strategy @@ -35,7 +36,7 @@ public function authenticate(): GithubApiClient { try { return $this->authService->getAuthenticatedClient(); - } catch (\Exception $e) { + } catch (Exception $e) { throw new RuntimeException( "GitHub App authentication failed: {$e->getMessage()}", 0, @@ -60,7 +61,7 @@ public function getJwtAuthenticatedClient(): GithubApiClient { try { return $this->authService->getJwtAuthenticatedClient(); - } catch (\Exception $e) { + } catch (Exception $e) { throw new RuntimeException( "GitHub App JWT authentication failed: {$e->getMessage()}", 0, diff --git a/src/Auth/GitHubJwtGenerator.php b/src/Auth/GitHubJwtGenerator.php new file mode 100644 index 00000000..a709b611 --- /dev/null +++ b/src/Auth/GitHubJwtGenerator.php @@ -0,0 +1,65 @@ +generator = new Rs256Generator(); + } + + /** + * Generate a JWT for GitHub App authentication + * + * @param int $appId GitHub App ID (iss claim) + * @param PrivateKey $privateKey Private key for signing + * @param int $expirySeconds JWT expiry time in seconds (max 600 for GitHub) + * @return GeneratedJwt + * @throws RuntimeException If JWT generation fails or expiry exceeds GitHub limit + */ + public function generate( + int $appId, + PrivateKey $privateKey, + int $expirySeconds = 600 + ): GeneratedJwt { + if ($expirySeconds > 600) { + throw new RuntimeException( + 'GitHub App JWTs cannot have expiry greater than 600 seconds (10 minutes)' + ); + } + + // Convert components types to Core types + $corePrivateKey = CorePrivateKey::fromString($privateKey->content); + + // Generate JWT using Core implementation + $coreJwt = $this->generator->generate($appId, $corePrivateKey, $expirySeconds); + + // Convert Core result back to components type + return new GeneratedJwt($coreJwt->token, $coreJwt->expiresAt); + } +} diff --git a/src/Auth/JwtGeneratorInterface.php b/src/Auth/JwtGeneratorInterface.php index 11499e77..171d830a 100644 --- a/src/Auth/JwtGeneratorInterface.php +++ b/src/Auth/JwtGeneratorInterface.php @@ -4,6 +4,8 @@ namespace Horde\Components\Auth; +use RuntimeException; + /** * Interface for JWT generation * @@ -25,7 +27,7 @@ interface JwtGeneratorInterface * @param PrivateKey $privateKey Private key for signing * @param int $expirySeconds JWT expiry time in seconds (max 600 for GitHub) * @return GeneratedJwt - * @throws \RuntimeException If JWT generation fails + * @throws RuntimeException If JWT generation fails */ public function generate( int $appId, diff --git a/src/Ci/Config/CiConfig.php b/src/Ci/Config/CiConfig.php index 1207639b..4c3fca32 100644 --- a/src/Ci/Config/CiConfig.php +++ b/src/Ci/Config/CiConfig.php @@ -16,6 +16,8 @@ namespace Horde\Components\Ci\Config; +use InvalidArgumentException; + /** * CI configuration container. * @@ -126,7 +128,7 @@ public function __construct(array $config) // Components path must be provided - no guessing if (!isset($config['components_path'])) { - throw new \InvalidArgumentException('components_path must be provided in configuration'); + throw new InvalidArgumentException('components_path must be provided in configuration'); } $this->componentsPath = $config['components_path']; } diff --git a/src/Ci/Init/InitCommand.php b/src/Ci/Init/InitCommand.php index cd4114aa..89a9e734 100644 --- a/src/Ci/Init/InitCommand.php +++ b/src/Ci/Init/InitCommand.php @@ -262,7 +262,7 @@ private function generateBootstrapScript( // Create directory if needed $dir = dirname($outputFile); if (!is_dir($dir)) { - if (!mkdir($dir, 0755, true)) { + if (!mkdir($dir, 0o755, true)) { throw new Exception("Failed to create directory: {$dir}"); } } @@ -273,7 +273,7 @@ private function generateBootstrapScript( } // Make executable - chmod($outputFile, 0755); + chmod($outputFile, 0o755); $this->output->ok("Created: {$outputFile}"); return true; @@ -321,7 +321,7 @@ private function generateWorkflow( // Create directory if needed $dir = dirname($outputFile); if (!is_dir($dir)) { - if (!mkdir($dir, 0755, true)) { + if (!mkdir($dir, 0o755, true)) { throw new Exception("Failed to create directory: {$dir}"); } } diff --git a/src/Ci/Run/RunCommand.php b/src/Ci/Run/RunCommand.php index d4c4ee65..ef6bd4ce 100644 --- a/src/Ci/Run/RunCommand.php +++ b/src/Ci/Run/RunCommand.php @@ -516,228 +516,230 @@ private function generateHtmlReport(): string $summary = $this->collector->getSummary(); $timestamp = date('Y-m-d H:i:s'); - $statusClass = $summary['failed'] === 0 ? 'success' : 'failure'; - $statusText = $summary['failed'] === 0 - ? "✅ All {$summary['total']} lanes passed" - : "❌ {$summary['failed']}/{$summary['total']} lanes failed"; + $failed = $summary['failed'] ?? 0; + $total = $summary['total'] ?? 0; + $statusClass = $failed === 0 ? 'success' : 'failure'; + $statusText = $failed === 0 + ? "✅ All {$total} lanes passed" + : "❌ {$failed}/{$total} lanes failed"; $html = << - - - - - Horde CI Report - {$timestamp} - - - -
-

🔍 Horde CI Report

-
Generated: {$timestamp}
- -
- {$statusText} -
- -

Lane Results

- - - - - - - - - - - - -HTML; + + + + + + Horde CI Report - {$timestamp} + + + +
+

🔍 Horde CI Report

+
Generated: {$timestamp}
+ +
+ {$statusText} +
+ +

Lane Results

+
LanePHPUnitPHPStanPHP-CS-FixerStatus
+ + + + + + + + + + + + HTML; // Generate table rows foreach ($results as $laneName => $tools) { @@ -755,25 +757,25 @@ private function generateHtmlReport(): string } $html .= << -
LanePHPUnitPHPStanPHP-CS-FixerStatus
+ + -

Detailed Metrics

+

Detailed Metrics

-HTML; + HTML; // Add detailed metrics $html .= $this->generateHtmlMetrics($results); $html .= << - CI powered by horde-components -
- - - -HTML; + + + + + HTML; return $html; } @@ -849,41 +851,41 @@ private function generateHtmlMetrics(array $results): string : 'Tests failed'; $html .= << -

{$statusIcon} PHPUnit - {$statusText}

-
- Lanes executed: - {$lanesRun} -
-
- Total tests: - {$totalTests} -
-
- Total assertions: - {$assertions} -
- -HTML; +
+

{$statusIcon} PHPUnit - {$statusText}

+
+ Lanes executed: + {$lanesRun} +
+
+ Total tests: + {$totalTests} +
+
+ Total assertions: + {$assertions} +
+ + HTML; if ($totalFailures > 0) { $html .= << - Failures: - {$totalFailures} -
+
+ Failures: + {$totalFailures} +
-HTML; + HTML; } if ($totalErrors > 0) { $html .= << - Errors: - {$totalErrors} - +
+ Errors: + {$totalErrors} +
-HTML; + HTML; } $html .= " \n"; @@ -901,23 +903,23 @@ private function generateHtmlMetrics(array $results): string : "{$totalErrors} errors found"; $html .= << -

{$statusIcon} PHPStan - {$statusText}

-
- Lanes executed: - {$lanesRun} -
-
- Files analyzed: - {$filesAnalyzed} -
-
- Errors: - {$totalErrors} -
- - -HTML; +
+

{$statusIcon} PHPStan - {$statusText}

+
+ Lanes executed: + {$lanesRun} +
+
+ Files analyzed: + {$filesAnalyzed} +
+
+ Errors: + {$totalErrors} +
+
+ + HTML; } // PHP-CS-Fixer section @@ -931,19 +933,19 @@ private function generateHtmlMetrics(array $results): string : "{$filesWithIssues} files with issues"; $html .= << -

{$statusIcon} PHP-CS-Fixer - {$statusText}

-
- Files checked: - {$filesChecked} -
-
- Files with issues: - {$filesWithIssues} -
- - -HTML; +
+

{$statusIcon} PHP-CS-Fixer - {$statusText}

+
+ Files checked: + {$filesChecked} +
+
+ Files with issues: + {$filesWithIssues} +
+
+ + HTML; } return $html; diff --git a/src/Ci/Setup/EnvironmentDetector.php b/src/Ci/Setup/EnvironmentDetector.php index 5fa736aa..19b9782e 100644 --- a/src/Ci/Setup/EnvironmentDetector.php +++ b/src/Ci/Setup/EnvironmentDetector.php @@ -18,6 +18,7 @@ use Horde\Components\Exception; use Horde\Components\Helper\Git; +use Phar; /** * Detects CI environment and extracts configuration. @@ -175,8 +176,8 @@ public static function getLocalComponentPath(): ?string public static function getComponentsExecutablePath(): string { // Check if we're running from a PHAR - if (class_exists('Phar') && \Phar::running(false) !== '') { - return \Phar::running(false); + if (class_exists('Phar') && Phar::running(false) !== '') { + return Phar::running(false); } // Not a PHAR - find the bin/horde-components script @@ -221,8 +222,8 @@ public static function getComponentsPharUrl(): string */ public static function buildConfig(?string $mode = null, ?string $componentPath = null): array { - $mode = $mode ?? self::detectMode(); - $componentPath = $componentPath ?? getcwd(); + $mode ??= self::detectMode(); + $componentPath ??= getcwd(); return [ 'mode' => $mode, diff --git a/src/Ci/Setup/LaneCopier.php b/src/Ci/Setup/LaneCopier.php index 207da4c3..55f34978 100644 --- a/src/Ci/Setup/LaneCopier.php +++ b/src/Ci/Setup/LaneCopier.php @@ -82,7 +82,7 @@ private function copyLane(string $sourcePath, string $targetPath): bool // Create parent directory $parentDir = dirname($targetPath); if (!is_dir($parentDir)) { - if (!mkdir($parentDir, 0755, true)) { + if (!mkdir($parentDir, 0o755, true)) { throw new Exception("Failed to create directory: {$parentDir}"); } } @@ -161,7 +161,7 @@ public function getLaneSize(string $laneDir): float } $parts = explode("\t", trim($output)); - return isset($parts[0]) ? (float)$parts[0] : 0.0; + return isset($parts[0]) ? (float) $parts[0] : 0.0; } /** diff --git a/src/Ci/Setup/LaneScriptGenerator.php b/src/Ci/Setup/LaneScriptGenerator.php index b376d101..99019ed1 100644 --- a/src/Ci/Setup/LaneScriptGenerator.php +++ b/src/Ci/Setup/LaneScriptGenerator.php @@ -66,7 +66,7 @@ public function generate(string $scriptPath, array $config): bool // Write script to file $dir = dirname($scriptPath); - if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) { + if (!is_dir($dir) && !@mkdir($dir, 0o755, true) && !is_dir($dir)) { $this->output->error("Failed to create directory: {$dir}"); return false; } @@ -77,7 +77,7 @@ public function generate(string $scriptPath, array $config): bool } // Make script executable - if (!chmod($scriptPath, 0755)) { + if (!chmod($scriptPath, 0o755)) { $this->output->error("Failed to make script executable: {$scriptPath}"); return false; } @@ -119,35 +119,35 @@ private function generateScriptContent(array $config, array $tasks): string $componentDir = $config['component_dir']; $toolsDir = $config['tools_dir']; $buildDir = $config['build_dir']; - $componentsPath = $config['components_path']; + $componentsPath = $config['components_path'] ?? '/usr/bin/horde-components'; $timestamp = date('Y-m-d H:i:s T'); $script = <<&1 || PHPUNIT_EXIT=$? -# JSON written to: $BUILD_DIR/phpunit-results-summary.json + # Task: PHPUnit + echo "=== Running PHPUnit ===" + cd "$COMPONENT_DIR" + "$PHP_BINARY" "$COMPONENTS_PATH" qc unit \ + --tools-dir="$TOOLS_DIR" \ + 2>&1 || PHPUNIT_EXIT=$? + # JSON written to: $BUILD_DIR/phpunit-results-summary.json -BASH; + BASH; } /** @@ -210,16 +210,16 @@ private function generatePhpUnitSection(string $phpVersion): string private function generatePhpStanSection(): string { return <<<'BASH' -# Task: PHPStan -echo "=== Running PHPStan ===" -cd "$COMPONENT_DIR" -"$PHP_BINARY" "$COMPONENTS_PATH" qc phpstan \ - --tools-dir="$TOOLS_DIR" \ - 2>&1 || PHPSTAN_EXIT=$? -# JSON written to: $BUILD_DIR/phpstan-results.json + # Task: PHPStan + echo "=== Running PHPStan ===" + cd "$COMPONENT_DIR" + "$PHP_BINARY" "$COMPONENTS_PATH" qc phpstan \ + --tools-dir="$TOOLS_DIR" \ + 2>&1 || PHPSTAN_EXIT=$? + # JSON written to: $BUILD_DIR/phpstan-results.json -BASH; + BASH; } /** @@ -230,16 +230,16 @@ private function generatePhpStanSection(): string private function generatePhpCsFixerSection(): string { return <<<'BASH' -# Task: PHP-CS-Fixer -echo "=== Running PHP-CS-Fixer ===" -cd "$COMPONENT_DIR" -"$PHP_BINARY" "$COMPONENTS_PATH" qc phpcsfixer \ - --tools-dir="$TOOLS_DIR" \ - 2>&1 || PHPCS_EXIT=$? -# JSON written to: $BUILD_DIR/php-cs-fixer-results.json + # Task: PHP-CS-Fixer + echo "=== Running PHP-CS-Fixer ===" + cd "$COMPONENT_DIR" + "$PHP_BINARY" "$COMPONENTS_PATH" qc phpcsfixer \ + --tools-dir="$TOOLS_DIR" \ + 2>&1 || PHPCS_EXIT=$? + # JSON written to: $BUILD_DIR/php-cs-fixer-results.json -BASH; + BASH; } /** @@ -251,11 +251,11 @@ private function generatePhpCsFixerSection(): string private function generateSummarySection(array $tasks): string { $summary = <<<'BASH' -# Summary -echo "" -echo "=== Lane $LANE_NAME Summary ===" + # Summary + echo "" + echo "=== Lane $LANE_NAME Summary ===" -BASH; + BASH; // Add exit code display for each task if (in_array('phpunit', $tasks)) { diff --git a/src/Ci/Setup/PhpInstaller.php b/src/Ci/Setup/PhpInstaller.php index 80a1e69e..2440c30e 100644 --- a/src/Ci/Setup/PhpInstaller.php +++ b/src/Ci/Setup/PhpInstaller.php @@ -110,9 +110,9 @@ public function install(array $versions): bool */ private function isDebianBased(): bool { - return file_exists('/etc/debian_version') || - file_exists('/etc/lsb-release') || - is_executable('/usr/bin/apt-get'); + return file_exists('/etc/debian_version') + || file_exists('/etc/lsb-release') + || is_executable('/usr/bin/apt-get'); } /** diff --git a/src/Ci/Setup/SetupCommand.php b/src/Ci/Setup/SetupCommand.php index 71494698..3e3791cc 100644 --- a/src/Ci/Setup/SetupCommand.php +++ b/src/Ci/Setup/SetupCommand.php @@ -114,8 +114,8 @@ public function execute(CiConfig $config): bool // Check component type support if ($config->componentType !== 'library') { throw new Exception( - "Component type '{$config->componentType}' not yet implemented. " . - "Only 'library' is supported in Phase 1." + "Component type '{$config->componentType}' not yet implemented. " + . "Only 'library' is supported in Phase 1." ); } diff --git a/src/Ci/Setup/ToolCache.php b/src/Ci/Setup/ToolCache.php index 0b52505e..9393f3c1 100644 --- a/src/Ci/Setup/ToolCache.php +++ b/src/Ci/Setup/ToolCache.php @@ -18,6 +18,8 @@ use Horde\Components\Output; use Horde\Components\Exception; +use Phar; +use Throwable; /** * Manages cached QC tool binaries (PHPUnit, PHPStan, PHP-CS-Fixer). @@ -68,7 +70,7 @@ public function ensureAllTools(array $phpVersions): void { // Create cache directory if (!is_dir($this->cacheDir)) { - mkdir($this->cacheDir, 0755, true); + mkdir($this->cacheDir, 0o755, true); } // Download PHPUnit for each PHP version @@ -233,7 +235,7 @@ private function downloadPhar(string $url, string $destination): void } // Make executable - chmod($destination, 0755); + chmod($destination, 0o755); } /** @@ -249,9 +251,9 @@ private function isValidPhar(string $path): bool } try { - \Phar::loadPhar($path); + Phar::loadPhar($path); return true; - } catch (\Throwable $e) { + } catch (Throwable $e) { return false; } } diff --git a/src/Cli/ModuleProvider.php b/src/Cli/ModuleProvider.php index 4e44371c..f7b8e696 100644 --- a/src/Cli/ModuleProvider.php +++ b/src/Cli/ModuleProvider.php @@ -15,6 +15,7 @@ use Horde\Components\Module\Composer; use Horde\Components\Module\ConventionalCommit; use Horde\Components\Module\Change; +use Horde\Components\Module\DatabaseModule; use Horde\Components\Module\Init; use Horde\Components\Module\InstallModule; use Horde\Components\Module\Package; @@ -55,6 +56,7 @@ public function getModules(): Modules $this->injector->get(Ci::class), $this->injector->get(ConfigModule::class), $this->injector->get(Composer::class), + $this->injector->get(DatabaseModule::class), $this->injector->get(Git::class), $this->injector->get(ConventionalCommit::class), $this->injector->get(Help::class), diff --git a/src/Component.php b/src/Component.php index f7014208..a61d6958 100644 --- a/src/Component.php +++ b/src/Component.php @@ -150,7 +150,7 @@ public function getDependencyList(); * Return a data array with the most relevant information about this * component. * - * @return \stdClass Information about this component. + * @return stdClass Information about this component. */ public function getData(): stdClass; diff --git a/src/Component/Archive.php b/src/Component/Archive.php index e0bed5a2..2cc309c3 100644 --- a/src/Component/Archive.php +++ b/src/Component/Archive.php @@ -16,6 +16,7 @@ use Horde\Components\Pear\Environment as PearEnvironment; use Horde\Components\Wrapper\PackageXml; use Horde_Pear_Package_Xml; +use Horde_Util; /** * Represents a component archive. @@ -99,7 +100,7 @@ public function install( /** * Return a PEAR package representation for the component. * - * @return \Horde_Pear_Package_Xml The package representation. + * @return Horde_Pear_Package_Xml The package representation. */ protected function getPackageXml(): PackageXml { @@ -123,7 +124,7 @@ protected function getPackageXml(): PackageXml private function _loadPackageFromArchive(): string { if (!function_exists('gzopen')) { - $tmpDir = \Horde_Util::createTempDir(); + $tmpDir = Horde_Util::createTempDir(); copy($this->_archive, $tmpDir . '/archive.tgz'); system('cd ' . $tmpDir . ' && tar zxpf archive.tgz'); if (!is_file($tmpDir . '/package.xml')) { @@ -171,7 +172,7 @@ private function _loadPackageFromArchive(): string } $package = substr($tar, 512, $filesize); - $tmpFile = \Horde_Util::getTempFile(); + $tmpFile = Horde_Util::getTempFile(); file_put_contents($tmpFile, $package); return $tmpFile; } diff --git a/src/Component/Base.php b/src/Component/Base.php index febfcbb7..63c41c8e 100644 --- a/src/Component/Base.php +++ b/src/Component/Base.php @@ -26,6 +26,7 @@ use Horde\Http\ResponseFactory; use Horde\Http\ClientException; use stdClass; +use Horde_Pear_Package_Xml; /** * Represents base functionality for a component. @@ -443,7 +444,7 @@ protected function createDestination($destination) /** * Return a PEAR package representation for the component. * - * @return \Horde_Pear_Package_Xml The package representation. + * @return Horde_Pear_Package_Xml The package representation. * @throws Exception */ protected function getPackageXml(): WrapperPackageXml diff --git a/src/Component/Dependency.php b/src/Component/Dependency.php index a4c99f6a..dc55cd0d 100644 --- a/src/Component/Dependency.php +++ b/src/Component/Dependency.php @@ -12,6 +12,7 @@ namespace Horde\Components\Component; use Horde\Components\Component; +use PEAR; /** * Horde\Components\Component\Dependency:: wraps PEAR dependency information. @@ -96,7 +97,7 @@ public function __construct( * * @return Component The component. */ - public function getComponent($options = []): bool|\Horde\Components\Component + public function getComponent($options = []): bool|Component { return $this->_factory->createResolver() ->resolveDependency($this, $options); @@ -181,7 +182,7 @@ public function isExtension(): bool */ public function isPearBase(): bool { - if ($this->_name == \PEAR::class && $this->_channel == 'pear.php.net') { + if ($this->_name == PEAR::class && $this->_channel == 'pear.php.net') { return true; } return false; diff --git a/src/Component/Factory.php b/src/Component/Factory.php index 5ff5f004..843d6ad5 100644 --- a/src/Component/Factory.php +++ b/src/Component/Factory.php @@ -23,6 +23,14 @@ use Horde\Components\Output; use Horde\Components\Pear\Factory as PearFactory; use Horde\Components\Release\Notes as ReleaseNotes; +use Horde_Http_Client; +use Horde_Pear_Exception; +use Horde_Pear_Package_Contents_List; +use Horde_Pear_Package_Type_HordeSplit; +use Horde_Pear_Package_Type_HordeTheme; +use Horde_Pear_Package_Xml; +use Horde_Pear_Remote; +use Horde_Release_Sentinel; /** * Generates component instances and helpers. @@ -61,11 +69,11 @@ class Factory * * @param array $_options Options for the current job. * @param PearFactory $_factory Generator for all required PEAR components. - * @param \Horde_Http_Client $_client The HTTP client for remote access. + * @param Horde_Http_Client $_client The HTTP client for remote access. * @param Output $_output The output handler. * @param ReleaseNotes $_notes The release notes. */ - public function __construct(protected array $_options, protected PearFactory $_factory, protected \Horde_Http_Client $_client, protected Output $_output, protected ReleaseNotes $_notes) {} + public function __construct(protected array $_options, protected PearFactory $_factory, protected Horde_Http_Client $_client, protected Output $_output, protected ReleaseNotes $_notes) {} /** * Create a representation for a source component. @@ -74,7 +82,7 @@ public function __construct(protected array $_options, protected PearFactory $_f * * @return Source The source component. */ - public function createSource($directory): \Horde\Components\Component\Source + public function createSource($directory): Source { $component = new Source( new ComponentDirectory($directory), @@ -94,7 +102,7 @@ public function createSource($directory): \Horde\Components\Component\Source * * @return Archive The archive component. */ - public function createArchive($archive): \Horde\Components\Component\Archive + public function createArchive($archive): Archive { $component = new Archive( $archive, @@ -109,7 +117,7 @@ public function createArchive($archive): \Horde\Components\Component\Archive * @param string $name The name of the component. * @param string $stability The stability of the component. * @param string $channel The component channel. - * @param \Horde_Pear_Remote $remote The remote server handler. + * @param Horde_Pear_Remote $remote The remote server handler. * * @return Remote The remote component. */ @@ -117,8 +125,8 @@ public function createRemote( $name, $stability, $channel, - \Horde_Pear_Remote $remote - ): \Horde\Components\Component\Remote { + Horde_Pear_Remote $remote + ): Remote { return new Remote( $name, $stability, @@ -158,7 +166,7 @@ public function pear(): PearFactory * * @return DependencyList The dependency list. */ - public function createDependencyList(Component $component): \Horde\Components\Component\DependencyList + public function createDependencyList(Component $component): DependencyList { return new DependencyList($component, $this); } @@ -170,7 +178,7 @@ public function createDependencyList(Component $component): \Horde\Components\Co * * @return Dependency The dependency. */ - public function createDependency($dependencies): \Horde\Components\Component\Dependency + public function createDependency($dependencies): Dependency { return new Dependency($dependencies, $this); } @@ -180,7 +188,7 @@ public function createDependency($dependencies): \Horde\Components\Component\Dep * * @return Resolver The component resolver. */ - public function getResolver(): \Horde\Components\Component\Resolver + public function getResolver(): Resolver { if (!isset($this->_resolver)) { $this->_resolver = $this->createResolver(); @@ -193,7 +201,7 @@ public function getResolver(): \Horde\Components\Component\Resolver * * @return Resolver The component resolver. */ - public function createResolver(): \Horde\Components\Component\Resolver + public function createResolver(): Resolver { return new Resolver( $this->getGitRoot(), @@ -206,11 +214,11 @@ public function createResolver(): \Horde\Components\Component\Resolver * * @param string $channel The channel name. * - * @return \Horde_Pear_Remote The remote handler. + * @return Horde_Pear_Remote The remote handler. */ - public function createRemoteChannel($channel): \Horde_Pear_Remote + public function createRemoteChannel($channel): Horde_Pear_Remote { - return new \Horde_Pear_Remote($channel); + return new Horde_Pear_Remote($channel); } /** @@ -218,11 +226,11 @@ public function createRemoteChannel($channel): \Horde_Pear_Remote * * @param string $directory The directory the sentinel should act in. * - * @return \Horde_Release_Sentinel The sentinel helper. + * @return Horde_Release_Sentinel The sentinel helper. */ - public function createSentinel($directory): \Horde_Release_Sentinel + public function createSentinel($directory): Horde_Release_Sentinel { - return new \Horde_Release_Sentinel($directory); + return new Horde_Release_Sentinel($directory); } /** @@ -262,9 +270,9 @@ public function createGitRoot() * * @param string $package_xml_path Path to the package.xml file. */ - public function createPackageXml($package_xml_path): \Horde_Pear_Package_Xml + public function createPackageXml($package_xml_path): Horde_Pear_Package_Xml { - return new \Horde_Pear_Package_Xml($package_xml_path); + return new Horde_Pear_Package_Xml($package_xml_path); } /** @@ -273,11 +281,11 @@ public function createPackageXml($package_xml_path): \Horde_Pear_Package_Xml * @param string $package_xml_dir Path to the parent directory of the * new package.xml file. * - * @throws \Horde_Pear_Exception + * @throws Horde_Pear_Exception */ public function createPackageFile($package_xml_dir): void { - $type = new \Horde_Pear_Package_Type_HordeSplit($package_xml_dir); + $type = new Horde_Pear_Package_Type_HordeSplit($package_xml_dir); $type->writePackageXmlDraft(); } @@ -287,11 +295,11 @@ public function createPackageFile($package_xml_dir): void * @param string $package_xml_dir Path to the parent directory of the * new package.xml file. * - * @throws \Horde_Pear_Exception + * @throws Horde_Pear_Exception */ public function createThemePackageFile($package_xml_dir): void { - $type = new \Horde_Pear_Package_Type_HordeTheme($package_xml_dir); + $type = new Horde_Pear_Package_Type_HordeTheme($package_xml_dir); $type->writePackageXmlDraft(); } @@ -303,13 +311,13 @@ public function createThemePackageFile($package_xml_dir): void * * @throws Exception */ - public function createContentList($package_xml_dir): \Horde_Pear_Package_Contents_List + public function createContentList($package_xml_dir): Horde_Pear_Package_Contents_List { - $type = new \Horde_Pear_Package_Type_HordeSplit( + $type = new Horde_Pear_Package_Type_HordeSplit( $package_xml_dir, $this->getGitRoot()->getRoot() ); - return new \Horde_Pear_Package_Contents_List($type); + return new Horde_Pear_Package_Contents_List($type); } /** @@ -320,10 +328,10 @@ public function createContentList($package_xml_dir): \Horde_Pear_Package_Content * * @throws Exception */ - public function createThemeContentList($package_xml_dir): \Horde_Pear_Package_Contents_List + public function createThemeContentList($package_xml_dir): Horde_Pear_Package_Contents_List { - return new \Horde_Pear_Package_Contents_List( - new \Horde_Pear_Package_Type_HordeTheme( + return new Horde_Pear_Package_Contents_List( + new Horde_Pear_Package_Type_HordeTheme( $package_xml_dir, $this->getGitRoot()->getRoot() ) diff --git a/src/Component/Remote.php b/src/Component/Remote.php index c1a0b019..202e0077 100644 --- a/src/Component/Remote.php +++ b/src/Component/Remote.php @@ -15,6 +15,9 @@ use stdClass; use Horde\Components\Pear\Environment as PearEnvironment; use Horde\Components\Wrapper\PackageXml; +use Horde_Http_Client; +use Horde_Pear_Package_Xml; +use Horde_Pear_Remote; /** * Represents a remote component. @@ -46,7 +49,7 @@ class Remote extends Base /** * The package file representing the component. */ - private ?\Horde_Pear_Package_Xml $_package = null; + private ?Horde_Pear_Package_Xml $_package = null; /** * Constructor. @@ -54,16 +57,16 @@ class Remote extends Base * @param string $_name Component name. * @param string $_stability Component stability. * @param string $_channel Component channel. - * @param \Horde_Pear_Remote $_remote Remote channel handler. - * @param \Horde_Http_Client $_client The HTTP client for remote access. + * @param Horde_Pear_Remote $_remote Remote channel handler. + * @param Horde_Http_Client $_client The HTTP client for remote access. * @param Factory $factory Generator for additional helpers. */ public function __construct( private $_name, private $_stability, private $_channel, - private readonly \Horde_Pear_Remote $_remote, - private readonly \Horde_Http_Client $_client, + private readonly Horde_Pear_Remote $_remote, + private readonly Horde_Http_Client $_client, Factory $factory ) { parent::__construct($factory); @@ -151,7 +154,7 @@ public function getDependencies(): array */ public function getData(): stdClass { - $data = new \stdClass(); + $data = new stdClass(); $release = $this->_remote->getLatestDetails($this->_name, null); $data->name = $this->_name; $data->summary = $release->getSummary(); diff --git a/src/Component/Resolver.php b/src/Component/Resolver.php index ed4d59ac..14b37f51 100644 --- a/src/Component/Resolver.php +++ b/src/Component/Resolver.php @@ -15,6 +15,8 @@ use Horde\Components\Component; use Horde\Components\Exception; use Horde\Components\Helper\Root as HelperRoot; +use DirectoryIterator; +use Horde_Pear_Remote; /** * Horde\Components\Component\Resolver:: resolves component names and dependencies @@ -58,7 +60,7 @@ public function __construct(private readonly HelperRoot $_root, private readonly * @return Component|boolean The component if the name could be * resolved. */ - public function resolveDependency(Dependency $dependency, $options): \Horde\Components\Component|bool + public function resolveDependency(Dependency $dependency, $options): Component|bool { return $this->resolveName( $dependency->getName(), @@ -77,7 +79,7 @@ public function resolveDependency(Dependency $dependency, $options): \Horde\Comp * @return Component|boolean The component if the name could be * resolved. */ - public function resolveName($name, $channel, $options): \Horde\Components\Component|bool + public function resolveName($name, $channel, $options): Component|bool { foreach ($this->_getAttempts($options) as $attempt) { if ($attempt == 'git' && $channel == 'pear.horde.org') { @@ -154,9 +156,9 @@ private function _getAttempts($options) * * @param string $channel The channel name. * - * @return \Horde_Pear_Remote The remote handler. + * @return Horde_Pear_Remote The remote handler. */ - private function _getRemote($channel): \Horde_Pear_Remote + private function _getRemote($channel): Horde_Pear_Remote { if (!isset($this->_remotes[$channel])) { $this->_remotes[$channel] = $this->_factory->createRemoteChannel( @@ -184,7 +186,7 @@ public function _identifyMatchingLocalPackage($name, $channel, $options): bool|s if (!file_exists($source)) { return false; } - foreach (new \DirectoryIterator($source) as $file) { + foreach (new DirectoryIterator($source) as $file) { if (preg_match('/' . $name . '-[0-9]+(\.[0-9]+)+([a-z0-9]+)?/', $file->getBasename('.tgz'), $matches)) { return $file->getPathname(); } diff --git a/src/Component/Source.php b/src/Component/Source.php index 755a6e91..c09a7909 100644 --- a/src/Component/Source.php +++ b/src/Component/Source.php @@ -33,6 +33,17 @@ use Horde\Components\Wrapper\HordeYml as WrapperHordeYml; use Horde\Components\Wrapper\PackageXml as WrapperPackageXml; use stdClass; +use DOMElement; +use Horde_Pear_Exception; +use Horde_Pear_Package_Xml; +use Horde_Release_Sentinel; +use InvalidArgumentException; +use PEAR_Common; +use PEAR_PackageFile; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; + +use function version_compare; /** * Represents a source component. @@ -48,7 +59,7 @@ class Source extends Base /** * The PEAR package file representing the component. * - * @var \PEAR_PackageFile + * @var PEAR_PackageFile */ protected $_package_file; @@ -134,7 +145,7 @@ public function getPreviousVersion(): string && $info['state']['release'] != 'stable') { continue; } - if (\version_compare($version, $currentVersion, '>=')) { + if (version_compare($version, $currentVersion, '>=')) { return $previousVersion; } $previousVersion = $version; @@ -312,7 +323,7 @@ public function getDocumentOrigin(): ?string if (!is_dir($path)) { continue; } - foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path)) as $file) { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)) as $file) { if ($file->isFile() && $file->getFilename() == 'DOCS_ORIGIN') { return [$file->getPathname(), $this->directory]; @@ -331,7 +342,7 @@ public function getDocumentOrigin(): ?string * * @return string The result of the action. * @throws Exception - * @throws \Horde_Pear_Exception + * @throws Horde_Pear_Exception * @throws NotFound */ public function updatePackage($action, $options): string @@ -381,7 +392,7 @@ public function updatePackage($action, $options): string * * @return WrapperPackageXml The updated package.xml handler. * @throws Exception - * @throws \Horde_Pear_Exception + * @throws Horde_Pear_Exception * @throws NotFound */ public function updatePackageFromHordeYml(): WrapperPackageXml @@ -428,7 +439,7 @@ public function updatePackageFromHordeYml(): WrapperPackageXml $yaml['license']['identifier'] ); if ($yaml['license']['uri']) { - /** @var \DOMElement $node */ + /** @var DOMElement $node */ $node = $xml->findNode('/p:package/p:license'); $node->setAttribute('uri', $yaml['license']['uri']); } @@ -461,7 +472,7 @@ public function updatePackageFromHordeYml(): WrapperPackageXml /** * Update dependencies. * - * @param \Horde_Pear_Package_Xml $xml A package.xml handler. + * @param Horde_Pear_Package_Xml $xml A package.xml handler. * @param array $dependencies A list of dependencies. * * @throws Exception @@ -501,7 +512,7 @@ protected function _updateDependencies($xml, $dependencies): void /** * Adds a number of dependencies of the same kind. * - * @param \Horde_Pear_Package_Xml $xml A package.xml handler. + * @param Horde_Pear_Package_Xml $xml A package.xml handler. * @param string $required A required dependency? Either * 'required' or 'optional'. * @param string $type A dependency type from .horde.yml. @@ -669,7 +680,7 @@ public function timestamp($options): string * * @return string The success message. * @throws Exception - * @throws \Horde_Pear_Exception + * @throws Horde_Pear_Exception */ public function sync($options): string { @@ -726,7 +737,7 @@ public function sync($options): string * * @return string Result message. * @throws Exception - * @throws \Horde_Pear_Exception + * @throws Horde_Pear_Exception */ public function setVersion( $rel_version = null, @@ -779,7 +790,7 @@ public function setVersion( * * @return Wrapper[] Wrappers of updated files. * @throws Exception - * @throws \Horde_Pear_Exception + * @throws Horde_Pear_Exception */ public function _setVersion($rel_version = null, $api_version = null): array { @@ -834,7 +845,7 @@ public function _setVersion($rel_version = null, $api_version = null): array * * @return string The success message. * @throws Exception - * @throws \Horde_Pear_Exception + * @throws Horde_Pear_Exception */ public function setState( $rel_state = null, @@ -884,7 +895,7 @@ public function setState( * * @return string The success message. * @throws Exception - * @throws \Horde_Pear_Exception + * @throws Horde_Pear_Exception */ public function nextVersion( $version, @@ -915,7 +926,7 @@ public function nextVersion( } $changelog[$version] = $nextVersion; $changelog->uksort( - fn($a, $b) => \version_compare($b, $a) + fn($a, $b) => version_compare($b, $a) ); $updated = $this->_setVersion($version); @@ -972,7 +983,7 @@ public function nextVersion( */ public function currentSentinel($changes, $app, $options): array { - /** @var \Horde_Release_Sentinel $sentinel */ + /** @var Horde_Release_Sentinel $sentinel */ $sentinel = $this->getFactory()->createSentinel($this->directory); if (empty($options['pretend'])) { $sentinel->replaceChanges($changes); @@ -1064,7 +1075,7 @@ public function placeArchive(string $destination, $options = []): array $old_dir = getcwd(); chdir($destination); try { - $pear_common = new \PEAR_Common(); + $pear_common = new PEAR_Common(); $result = ExceptionPear::catchError( $pkg->getDefaultGenerator()->toTgz($pear_common) ); @@ -1290,7 +1301,7 @@ public function getWrapper($file) ); break; default: - throw new \InvalidArgumentException( + throw new InvalidArgumentException( $file . ' is not a supported file wrapper' ); } diff --git a/src/Component/Task/SystemCallResult.php b/src/Component/Task/SystemCallResult.php index ae2b2dee..be62e165 100644 --- a/src/Component/Task/SystemCallResult.php +++ b/src/Component/Task/SystemCallResult.php @@ -13,6 +13,8 @@ namespace Horde\Components\Component\Task; +use Stringable; + /** * Components\Component\Task\SystemCallResult:: Holds Output, Return code etc * @@ -26,7 +28,7 @@ * @author Ralf Lang * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ -class SystemCallResult implements \Stringable +class SystemCallResult implements Stringable { protected $_fields = []; diff --git a/src/Components.php b/src/Components.php index 6b44c652..7a85b31b 100644 --- a/src/Components.php +++ b/src/Components.php @@ -46,6 +46,7 @@ use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; +use Horde_Cli_Modular; /** * The Components:: class is the entry point for the various component actions @@ -86,7 +87,7 @@ public static function main(array $parameters = []): void $dispatcher = new EventDispatcher($provider); // Setup the DI system and feed the container - whatever needs the event system will get it $injector = new Dependencies\Injector(new TopLevel()); - $injector->setInstance(EventDispatcherInterface::class, $dispatcher); + $injector->setInstance(EventdispatcherInterface::class, $dispatcher); $injector->setInstance(ListenerProviderInterface::class, $provider); $injector->setInstance(ArgvWrapper::class, ArgvWrapper::fromGlobal()); $app = new Components($injector, $parameters); @@ -106,7 +107,7 @@ public function __construct(Injector $injector, $parameters) $injector->setInstance(EnvironmentConfigProvider::class, $environmentConfig); $injector->setInstance(BuiltinConfigProvider::class, new BuiltinConfigProvider( [ - 'checkout.dir' => $environmentConfig->hasSetting('HOME') ? $environmentConfig->getSetting('HOME') . '/git/horde' : '/srv/git/horde', + 'checkout.dir' => $environmentConfig->hasSetting('HOME') ? $environmentConfig->getSetting('HOME') . '/git' : '/srv/git', 'repo.org' => 'horde', 'scm.domain' => 'https://github.com', 'scm.type' => 'github', @@ -198,7 +199,19 @@ public function __construct(Injector $injector, $parameters) } if (!$ran) { - $modular->getParser()->parserError(self::ERROR_NO_ACTION); + // Show brief help instead of parser error + $helpModule = null; + foreach ($modular->getModules() as $module) { + if ($module instanceof Module\Help) { + $helpModule = $module; + break; + } + } + if ($helpModule) { + $helpModule->showBriefHelp(); + } else { + $modular->getParser()->parserError(self::ERROR_NO_ACTION); + } } } @@ -279,7 +292,7 @@ protected static function _prepareDependencies($parameters) /** * Provide a list of available action arguments. */ - protected static function _getActionArguments(\Horde_Cli_Modular $modular): array + protected static function _getActionArguments(Horde_Cli_Modular $modular): array { $actions = []; foreach ($modular->getModules() as $module) { diff --git a/src/Config/Base.php b/src/Config/Base.php index 75983fc2..cbf71a94 100644 --- a/src/Config/Base.php +++ b/src/Config/Base.php @@ -49,7 +49,7 @@ abstract class Base implements Config /** * The selected component. */ - private ?\Horde\Components\Component $_component = null; + private ?Component $_component = null; /** * The path to component in case the selected one is a source component. diff --git a/src/ConfigProvider/ConfigProvider.php b/src/ConfigProvider/ConfigProvider.php index 91a5b55c..f860af2d 100644 --- a/src/ConfigProvider/ConfigProvider.php +++ b/src/ConfigProvider/ConfigProvider.php @@ -4,6 +4,8 @@ namespace Horde\Components\ConfigProvider; +use Exception; + /** * Configuration provider interface * @@ -37,7 +39,7 @@ public function hasSetting(string $id): bool; * * @param string $id The setting key * @return string The setting value - * @throws \Exception if the setting does not exist + * @throws Exception if the setting does not exist */ public function getSetting(string $id): string; diff --git a/src/Dependencies.php b/src/Dependencies.php index 0325be8f..da5bf4a4 100644 --- a/src/Dependencies.php +++ b/src/Dependencies.php @@ -26,6 +26,9 @@ use Horde\Components\Runner\Update as RunnerUpdate; use Horde\Components\Runner\Webdocs as RunnerWebdocs; use Psr\Container\ContainerInterface; +use Horde_Argv_Parser; +use Horde_Cli_Modular; +use Horde_Pear_Remote; /** * The Components_Dependencies:: interface is a central broker for @@ -55,11 +58,11 @@ public function getInstance(string $interface); /** * Set the list of modules. * - * @param \Horde_Cli_Modular $modules The list of modules. + * @param Horde_Cli_Modular $modules The list of modules. * * @return void */ - public function setModules(\Horde_Cli_Modular $modules); + public function setModules(Horde_Cli_Modular $modules); /** * Return the list of modules. @@ -71,7 +74,7 @@ public function getModules(); /** * Set the CLI parser. * - * @param \Horde_Argv_Parser $parser The parser. + * @param Horde_Argv_Parser $parser The parser. * * @return void */ @@ -150,7 +153,7 @@ public function getRunnerWebdocs(); /** * Returns the documentation fetch handler for a package. * - * @return RunnerFetchdocs The fetch handler. + * @return RunnerFetchDocs The fetch handler. */ public function getRunnerFetchdocs(); @@ -199,7 +202,7 @@ public function getComponentFactory(); /** * Returns the handler for remote PEAR servers. * - * @return \Horde_Pear_Remote The handler. + * @return Horde_Pear_Remote The handler. */ public function getRemote(); } diff --git a/src/Dependencies/GitCheckoutDirectoryFactory.php b/src/Dependencies/GitCheckoutDirectoryFactory.php index 6969aae6..997eb2c3 100644 --- a/src/Dependencies/GitCheckoutDirectoryFactory.php +++ b/src/Dependencies/GitCheckoutDirectoryFactory.php @@ -3,40 +3,47 @@ namespace Horde\Components\Dependencies; use Horde\Components\RuntimeContext\GitCheckoutDirectory; +use Horde\Components\ConfigProvider\EffectiveConfigProvider; use Horde\Components\ConfigProvider\EnvironmentConfigProvider; class GitCheckoutDirectoryFactory { public function __construct( - private EnvironmentConfigProvider $environmentConfig + private EffectiveConfigProvider|EnvironmentConfigProvider $config ) {} /** * Setup Git Checkout Directory * - * Priority of checkout_dir: - * 1. checkout_dir from config + * Priority of checkout.dir: + * 1. checkout.dir from config (includes file + env + defaults) * 2. HORDE_GIT_DIR environment variable - * 3. HOME/git/horde - * 4. /srv/git/horde + * 3. HOME/git + * 4. /srv/git + * + * NOTE: checkout.dir should point to the parent of vendor directories. + * Components are located at: checkout.dir/vendor/component + * Example: /home/user/git/horde/ActiveSync */ public function __invoke(): GitCheckoutDirectory { - // Check for checkout_dir in config first - $checkoutDir = $this->environmentConfig->hasSetting('checkout_dir') - ? $this->environmentConfig->getSetting('checkout_dir') + // Check for checkout.dir in config first (includes file + env + defaults) + $checkoutDir = $this->config->hasSetting('checkout.dir') + ? $this->config->getSetting('checkout.dir') : ''; + // Fallback to HORDE_GIT_DIR environment variable if (empty($checkoutDir)) { - $checkoutDir = $this->environmentConfig->hasSetting('HORDE_GIT_DIR') - ? $this->environmentConfig->getSetting('HORDE_GIT_DIR') + $checkoutDir = $this->config->hasSetting('HORDE_GIT_DIR') + ? $this->config->getSetting('HORDE_GIT_DIR') : ''; } + // Final fallback to default location if (empty($checkoutDir)) { - $checkoutDir = $this->environmentConfig->hasSetting('HOME') - ? $this->environmentConfig->getSetting('HOME') . '/git/horde' - : '/srv/git/horde'; + $checkoutDir = $this->config->hasSetting('HOME') + ? $this->config->getSetting('HOME') . '/git' + : '/srv/git'; } return new GitCheckoutDirectory($checkoutDir); diff --git a/src/Dependencies/Injector.php b/src/Dependencies/Injector.php index c3de38dc..3b6321a1 100644 --- a/src/Dependencies/Injector.php +++ b/src/Dependencies/Injector.php @@ -45,6 +45,12 @@ use Horde\Components\RuntimeContext\GitCheckoutDirectory; use Horde\Injector\Injector as HordeInjector; use Horde\Injector\TopLevel; +use Exception; +use Horde_Argv_Parser; +use Horde_Cli; +use Horde_Cli_Modular; +use Horde_Http_Client; +use Horde_Pear_Remote; /** * The Components_Dependencies_Injector:: class provides the @@ -85,7 +91,7 @@ public function __construct($parentInjector = null) 'createComponentFactory' ); $this->bindFactory( - \Horde_Cli::class, + Horde_Cli::class, Dependencies::class, 'createCli' ); @@ -134,7 +140,7 @@ public function __construct($parentInjector = null) public static function registerAppDependencies(HordeInjector $injector) { $injector->bindFactory( - \Horde_Cli::class, + Horde_Cli::class, Dependencies::class, 'createCli' ); @@ -173,41 +179,41 @@ public static function registerAppDependencies(HordeInjector $injector) /** * Set the list of modules. * - * @param \Horde_Cli_Modular $modules The list of modules. + * @param Horde_Cli_Modular $modules The list of modules. */ - public function setModules(\Horde_Cli_Modular $modules): void + public function setModules(Horde_Cli_Modular $modules): void { - $this->setInstance(\Horde_Cli_Modular::class, $modules); + $this->setInstance(Horde_Cli_Modular::class, $modules); } /** * Return the list of modules. * - * @return \Horde_Cli_Modular The list of modules. + * @return Horde_Cli_Modular The list of modules. */ public function getModules() { - return $this->getInstance(\Horde_Cli_Modular::class); + return $this->getInstance(Horde_Cli_Modular::class); } /** * Set the CLI parser. * - * @param \Horde_Argv_Parser $parser The parser. + * @param Horde_Argv_Parser $parser The parser. */ public function setParser($parser): void { - $this->setInstance(\Horde_Argv_Parser::class, $parser); + $this->setInstance(Horde_Argv_Parser::class, $parser); } /** * Return the CLI parser. * - * @return \Horde_Argv_Parser The parser. + * @return Horde_Argv_Parser The parser. */ public function getParser() { - return $this->getInstance(\Horde_Argv_Parser::class); + return $this->getInstance(Horde_Argv_Parser::class); } /** @@ -415,11 +421,11 @@ public function getComponentFactory() /** * Returns the handler for remote PEAR servers. * - * @return \Horde_Pear_Remote The handler. + * @return Horde_Pear_Remote The handler. */ public function getRemote() { - return $this->getInstance(\Horde_Pear_Remote::class); + return $this->getInstance(Horde_Pear_Remote::class); } /** @@ -446,14 +452,14 @@ public function createComponentFactory(): ComponentFactory $options[$key] = $configProvider->getSetting($key); } } - } catch (\Exception $e) { + } catch (Exception $e) { $options = []; } return new ComponentFactory( $options, $this->getInstance(\Horde\Components\Pear\Factory::class), - $this->getInstance(\Horde_Http_Client::class), + $this->getInstance(Horde_Http_Client::class), $this->getInstance(Output::class), $this->getInstance(ReleaseNotes::class) ); @@ -466,9 +472,9 @@ public function createComponentFactory(): ComponentFactory * with PHPUnit's exception handling in tests. We detect if running under * PHPUnit and avoid init() in that case. * - * @return \Horde_Cli The CLI handler. + * @return Horde_Cli The CLI handler. */ - public function createCli(): \Horde_Cli + public function createCli(): Horde_Cli { // Check if running under PHPUnit $isTestEnvironment = defined('PHPUNIT_COMPOSER_INSTALL') @@ -478,11 +484,11 @@ public function createCli(): \Horde_Cli if ($isTestEnvironment) { // In test environment, use constructor directly to avoid // set_exception_handler() call in Horde_Cli::init() - return new \Horde_Cli(['pager' => $this->_usePager]); + return new Horde_Cli(['pager' => $this->_usePager]); } // In production, use init() which sets up full CLI environment - return \Horde_Cli::init(['pager' => $this->_usePager]); + return Horde_Cli::init(['pager' => $this->_usePager]); } /** @@ -492,7 +498,7 @@ public function createCli(): \Horde_Cli * * @return Output The output handler. */ - public function createOutput(Injector $injector): \Horde\Components\Output + public function createOutput(Injector $injector): Output { // Get parsed options from DI if available, otherwise use empty array $options = []; @@ -501,7 +507,7 @@ public function createOutput(Injector $injector): \Horde\Components\Output } return new Output( - $injector->getInstance(\Horde_Cli::class), + $injector->getInstance(Horde_Cli::class), $options ); } diff --git a/src/Dependencies/InstallationDirectoryFactory.php b/src/Dependencies/InstallationDirectoryFactory.php index d2c3fd06..36a64e85 100644 --- a/src/Dependencies/InstallationDirectoryFactory.php +++ b/src/Dependencies/InstallationDirectoryFactory.php @@ -3,30 +3,33 @@ namespace Horde\Components\Dependencies; use Horde\Components\Composer\InstallationDirectory; +use Horde\Components\ConfigProvider\EffectiveConfigProvider; use Horde\Components\ConfigProvider\EnvironmentConfigProvider; class InstallationDirectoryFactory { public function __construct( - private EnvironmentConfigProvider $environmentConfig + private EffectiveConfigProvider|EnvironmentConfigProvider $config ) {} public function __invoke(): InstallationDirectory { - // Check for install_base in config, then environment, then defaults - $installationDir = $this->environmentConfig->hasSetting('install_base') - ? $this->environmentConfig->getSetting('install_base') + // Check for install.dir in config (includes file + env + defaults) + $installationDir = $this->config->hasSetting('install.dir') + ? $this->config->getSetting('install.dir') : ''; + // Fallback to HORDE_INSTALL_DIR environment variable if (empty($installationDir)) { - $installationDir = $this->environmentConfig->hasSetting('HORDE_INSTALL_DIR') - ? $this->environmentConfig->getSetting('HORDE_INSTALL_DIR') + $installationDir = $this->config->hasSetting('HORDE_INSTALL_DIR') + ? $this->config->getSetting('HORDE_INSTALL_DIR') : ''; } + // Final fallback to default location if (empty($installationDir)) { - $installationDir = $this->environmentConfig->hasSetting('HOME') - ? $this->environmentConfig->getSetting('HOME') . '/www/horde-dev' + $installationDir = $this->config->hasSetting('HOME') + ? $this->config->getSetting('HOME') . '/www/horde-dev' : '/srv/www/horde-dev'; } diff --git a/src/GitCommitRef.php b/src/GitCommitRef.php index 4e9e9572..047435e0 100644 --- a/src/GitCommitRef.php +++ b/src/GitCommitRef.php @@ -4,7 +4,7 @@ use Stringable; -class GitCommitRef implements \Stringable +class GitCommitRef implements Stringable { public function __construct( public readonly string|Stringable $refString = '', diff --git a/src/Helper/ChangeLog.php b/src/Helper/ChangeLog.php index 455f9e1f..93594fdf 100644 --- a/src/Helper/ChangeLog.php +++ b/src/Helper/ChangeLog.php @@ -19,6 +19,10 @@ use Horde\Components\Exception; use Horde\Components\Helper\Version as HelperVersion; use Horde\Components\Helper\Shell; +use Horde_Pear_Package_Xml; +use Horde_String; + +use function array_walk; /** * Helper for adding entries to the change log(s). @@ -135,7 +139,7 @@ public function setVersion($version, $api): void */ $changelogArr = $changelog->getArrayCopy(); $newChangelog = []; - \array_walk( + array_walk( $changelogArr, function ($entry, $ver) use (&$newChangelog, $oldVersion, $version, $api) { if ($ver == $oldVersion) { @@ -174,7 +178,7 @@ public function timestamp(): void /** * Builds a changelog.yml from an existing package.xml. * - * @param \Horde_Pear_Package_Xml $xml The package xml handler. + * @param Horde_Pear_Package_Xml $xml The package xml handler. */ public function migrateToChangelogYml($xml): void { @@ -259,7 +263,7 @@ public function migrateToChangelogYml($xml): void * Update package.xml file. * * @param string $log The log entry. - * @param \Horde_Pear_Package_Xml $xml The package xml handler. + * @param Horde_Pear_Package_Xml $xml The package xml handler. * * @return string Path to the updated package.xml file. */ @@ -275,7 +279,7 @@ public function packageXml($log, $xml): string /** * Updates package.xml from changelog.yml. * - * @param \Horde_Pear_Package_Xml $xml The package xml handler. + * @param Horde_Pear_Package_Xml $xml The package xml handler. * * @return string Path to the updated package.xml file. */ @@ -400,7 +404,7 @@ public function updateChanges() } else { $indent = 6; } - $entry = \Horde_String::wrap($entry, 79, "\n" . str_repeat(' ', $indent)); + $entry = Horde_String::wrap($entry, 79, "\n" . str_repeat(' ', $indent)); $changes->add("\n" . $entry); } } diff --git a/src/Helper/Composer.php b/src/Helper/Composer.php index 9b88a3f7..a10b96fc 100644 --- a/src/Helper/Composer.php +++ b/src/Helper/Composer.php @@ -23,6 +23,8 @@ use Horde\Components\Helper\Shell; use stdClass; use DirectoryIterator; +use Horde_Date; +use Horde_String; /** * @author Michael Slusarz @@ -150,34 +152,39 @@ public function update(string $packageDir, string $constraint = '') */ public function generateComposerJson(WrapperHordeYml $package, array $options = []): string|bool { - if (!empty($options['composer_opts']['pear-substitutes'])) { - $this->_substitutes = $options['composer_opts']['pear-substitutes']; + // Try new name first, then fall back to old name for backwards compatibility + if (!empty($options['composer.opts']['pear-substitutes']) || !empty($options['composer_opts']['pear-substitutes'])) { + $this->_substitutes = $options['composer.opts']['pear-substitutes'] ?? $options['composer_opts']['pear-substitutes']; } // Handle cases where vendor is not horde. $this->_vendor = $options['vendor'] ?? 'horde'; // The git repo base URL, defaults to github/vendor. - $this->_gitRepoBase = $options['git_repo_base'] + // Try new name first, then fall back to old name for backwards compatibility + $this->_gitRepoBase = $options['scm.repo.base'] + ?? $options['git_repo_base'] ?? 'https://github.com/' . $this->_vendor . '/'; // Decide on repo type hints - if (!empty($options['composer_repo'])) { - if ($options['composer_repo'] == 'vcs') { + if (!empty($options['composer.repo']) || !empty($options['composer_repo'])) { + $composerRepo = $options['composer.repo'] ?? $options['composer_repo']; + if ($composerRepo == 'vcs') { $this->_composerRepo = 'vcs'; } - if (substr((string) $options['composer_repo'], 0, 6) == 'satis:') { + if (substr((string) $composerRepo, 0, 6) == 'satis:') { $this->_composerRepo = 'composer'; $this->_repositories['composer'] = [ 'type' => 'composer', - 'url' => substr((string) $options['composer_repo'], 6), + 'url' => substr((string) $composerRepo, 6), ]; } } // Override horde dependency versions - if (!empty($options['composer_version'])) { - $this->_composerVersion = $options['composer_version']; + // Try new name first, then fall back to old name for backwards compatibility + if (!empty($options['composer.version']) || !empty($options['composer_version'])) { + $this->_composerVersion = $options['composer.version'] ?? $options['composer_version']; } $filename = dirname($package->getFullPath()) . '/composer.json'; - $composerDefinition = new \stdClass(); + $composerDefinition = new stdClass(); /** * Allow setting a minimum stability. * Normally we would either want the default (empty, stable) @@ -203,7 +210,7 @@ public function generateComposerJson(WrapperHordeYml $package, array $options = // Composer docs advise against writing the version tag to file // https://getcomposer.org/doc/04-schema.md#version // $composerDefinition->version = $version; - $composerDefinition->time = $package['time'] ?? (new \Horde_Date(time()))->format('Y-m-d'); + $composerDefinition->time = $package['time'] ?? (new Horde_Date(time()))->format('Y-m-d'); $composerDefinition->repositories = []; $this->_setRequire($package, $composerDefinition); $this->_setDevRequire($package, $composerDefinition); @@ -220,20 +227,20 @@ public function generateComposerJson(WrapperHordeYml $package, array $options = $version = $package->getReleaseVersion(); // Enforce suggest to be a json object rather than array if (empty($composerDefinition->suggest)) { - $composerDefinition->suggest = new \stdClass(); + $composerDefinition->suggest = new stdClass(); } if (empty($composerDefinition->{'require-dev'})) { - $composerDefinition->{'require-dev'} = new \stdClass(); + $composerDefinition->{'require-dev'} = new stdClass(); } $gitHelper = new Git(); $branch = $gitHelper->getCurrentBranch(dirname($package->getFullPath())); // branch alias if ($branch == 'FRAMEWORK_6_0') { if (empty($composerDefinition->extra)) { - $composerDefinition->extra = new \stdClass(); + $composerDefinition->extra = new stdClass(); } if (empty($composerDefinition->extra->{'branch-alias'})) { - $composerDefinition->extra->{'branch-alias'} = new \stdClass(); + $composerDefinition->extra->{'branch-alias'} = new stdClass(); } if (empty($composerDefinition->extra->{'branch-alias'}->{'dev-' . $branch})) { $composerDefinition->extra->{'branch-alias'}->{'dev-' . $branch} = $version->getMajor() . '.x-dev'; @@ -255,7 +262,7 @@ public function generateComposerJson(WrapperHordeYml $package, array $options = /** * Turn a provides: block in yml into a composer.json provide: block */ - public function _setProvides(WrapperHordeYml $package, \stdClass $composerDefinition): void + public function _setProvides(WrapperHordeYml $package, stdClass $composerDefinition): void { if (empty($package['provides'])) { return; @@ -274,7 +281,7 @@ public function _setProvides(WrapperHordeYml $package, \stdClass $composerDefini * Otherwise use provided whitelist "commands" * and blacklist "nocommands" (blacklist wins) */ - protected function _setVendorBin(WrapperHordeYml $package, \stdClass $composerDefinition): void + protected function _setVendorBin(WrapperHordeYml $package, stdClass $composerDefinition): void { $commands = []; $noCommands = []; @@ -290,7 +297,7 @@ protected function _setVendorBin(WrapperHordeYml $package, \stdClass $composerDe // No explicit list - search bindir $binDir = dirname($package->getFullPath()) . '/bin/'; if (is_dir($binDir)) { - foreach (new \DirectoryIterator($binDir) as $file) { + foreach (new DirectoryIterator($binDir) as $file) { if ($file->isExecutable() and $file->isFile()) { $commands[] = 'bin/' . $file->getFilename(); } @@ -310,14 +317,14 @@ protected function _setVendorBin(WrapperHordeYml $package, \stdClass $composerDe } } - protected function _setName(WrapperHordeYml $package, \stdClass $composerDefinition): void + protected function _setName(WrapperHordeYml $package, stdClass $composerDefinition): void { $vendor = $this->_vendor; - $name = \Horde_String::lower($package['name']); + $name = Horde_String::lower($package['name']); $composerDefinition->name = "$vendor/$name"; } - protected function _setType(WrapperHordeYml $package, \stdClass $composerDefinition): void + protected function _setType(WrapperHordeYml $package, stdClass $composerDefinition): void { if (!isset($package['type']) || ($package['type'] == 'library')) { // Only use custom type horde-library if we have to @@ -342,13 +349,16 @@ protected function _setType(WrapperHordeYml $package, \stdClass $composerDefinit // No type is perfectly valid for composer. Types for bundles? } - protected function _setAuthors(WrapperHordeYml $package, \stdClass $composerDefinition): void + protected function _setAuthors(WrapperHordeYml $package, stdClass $composerDefinition): void { $composerDefinition->authors = []; foreach ($package['authors'] as $author) { - $person = new \stdClass(); + $person = new stdClass(); $person->name = $author['name']; - $person->email = $author['email']; + // Only include email if it's present and not null + if (!empty($author['email'])) { + $person->email = $author['email']; + } $person->role = $author['role']; array_push($composerDefinition->authors, $person); } @@ -362,7 +372,7 @@ protected function _setAuthors(WrapperHordeYml $package, \stdClass $composerDefi * @param WrapperHordeYml A Yaml definition of the package * @param stdClass the composer definition file to build */ - protected function _setAutoload(WrapperHordeYml $package, \stdClass $composerDefinition): void + protected function _setAutoload(WrapperHordeYml $package, stdClass $composerDefinition): void { $composerDefinition->autoload = []; @@ -406,7 +416,7 @@ protected function _setAutoload(WrapperHordeYml $package, \stdClass $composerDef * @param WrapperHordeYml A Yaml definition of the package * @param stdClass the composer definition file to build */ - protected function _setAutoloadDev(WrapperHordeYml $package, \stdClass $composerDefinition): void + protected function _setAutoloadDev(WrapperHordeYml $package, stdClass $composerDefinition): void { $composerDefinition->{'autoload-dev'} = []; $parts = explode('_', (string) $package['name']); @@ -459,7 +469,7 @@ protected function _setAutoloadDev(WrapperHordeYml $package, \stdClass $composer * - a satis repo * - individual git repos */ - protected function _setRequire(WrapperHordeYml $package, \stdClass $composerDefinition): void + protected function _setRequire(WrapperHordeYml $package, stdClass $composerDefinition): void { $version = ($this->_composerVersion) ? $this->_composerVersion . " || ^3 || ^2" : '^3 || ^2'; // Only require the installer if we really need it @@ -474,6 +484,8 @@ protected function _setRequire(WrapperHordeYml $package, \stdClass $composerDefi if ($element == 'composer') { // composer dependencies which have no pear equivalent, i.e. unbundling foreach ($required as $dep => $version) { + // Lowercase package name (composer packages are always lowercase) + $dep = strtolower($dep); if ($this->_composerVersion && substr((string) $dep, 0, 5) == 'horde') { $composerDefinition->require[$dep] = "$version || $this->_composerVersion" ; } else { @@ -545,7 +557,7 @@ protected function _handleVersion($version, &$stack, $type, $repo, $basename, $v $this->_repositories['pear-' . $repo] = ['url' => 'https://' . $repo, 'type' => 'pear']; } else { // Most likely, this is always composer - $stack[\Horde_String::lower("$vendor/$basename")] = $version; + $stack[Horde_String::lower("$vendor/$basename")] = $version; if ($this->_composerRepo == 'vcs') { $this->_repositories["$vendor/$basename"] = ['url' => "https://github.com/$vendor/$repo", 'type' => 'vcs']; } @@ -563,7 +575,7 @@ protected function _addPearRepo($pear): void * * References to the horde pear channel will be changed to composer vcs/github */ - protected function _setSuggest(WrapperHordeYml $package, \stdClass $composerDefinition): void + protected function _setSuggest(WrapperHordeYml $package, stdClass $composerDefinition): void { $composerDefinition->suggest = []; if (empty($package['dependencies']['optional'])) { @@ -573,6 +585,8 @@ protected function _setSuggest(WrapperHordeYml $package, \stdClass $composerDefi if ($element == 'composer') { // composer dependencies which have no pear equivalent, i.e. unbundling foreach ($suggested as $dep => $version) { + // Lowercase package name (composer packages are always lowercase) + $dep = strtolower($dep); if ($this->_composerVersion && substr((string) $dep, 0, 5) == 'horde') { $composerDefinition->suggest[$dep] = "$version || $this->_composerVersion" ; } else { @@ -630,7 +644,7 @@ protected function _setSuggest(WrapperHordeYml $package, \stdClass $composerDefi * No known pear equivalent, this is composer-only * We still require the composer: key for consistency with optional */ - protected function _setDevRequire(WrapperHordeYml $package, \stdClass $composerDefinition): void + protected function _setDevRequire(WrapperHordeYml $package, stdClass $composerDefinition): void { $composerDefinition->{'require-dev'} = []; if (empty($package['dependencies']['dev'])) { @@ -640,6 +654,8 @@ protected function _setDevRequire(WrapperHordeYml $package, \stdClass $composerD if ($element == 'composer') { // composer dependencies which have no pear equivalent, i.e. unbundling foreach ($suggested as $dep => $version) { + // Lowercase package name (composer packages are always lowercase) + $dep = strtolower($dep); if ($this->_composerVersion && substr((string) $dep, 0, 5) == 'horde') { $composerDefinition->{'require-dev'}[$dep] = "$version || $this->_composerVersion" ; } else { @@ -671,12 +687,12 @@ protected function _substitute($pear, $version, &$stack): bool return false; } - protected function _setRepositories(WrapperHordeYml $package, \stdClass $composerDefinition): void + protected function _setRepositories(WrapperHordeYml $package, stdClass $composerDefinition): void { $composerDefinition->repositories = array_values($this->_repositories); } - protected function _setConfig(WrapperHordeYml $package, \stdClass $composerDefinition): void + protected function _setConfig(WrapperHordeYml $package, stdClass $composerDefinition): void { $plugins = $package->getAllowedPlugins(); if (!empty($plugins->all)) { diff --git a/src/Helper/Database/DatabaseManager.php b/src/Helper/Database/DatabaseManager.php new file mode 100644 index 00000000..8815e993 --- /dev/null +++ b/src/Helper/Database/DatabaseManager.php @@ -0,0 +1,351 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Helper\Database; + +use Horde\Components\Output; +use Horde\Components\ConfigProvider\PhpConfigFileProvider; +use PDO; +use PDOException; +use Exception; + +/** + * Database Manager - Handle database connections and operations + * + * Provides database connection management and common operations + * for SQLite, MySQL, and PostgreSQL databases. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class DatabaseManager +{ + private ?PDO $pdo = null; + private readonly string $type; + + /** + * Constructor. + * + * @param PhpConfigFileProvider $config Configuration provider + * @param Output $output Output handler + * @param string $type Database type (sqlite, mysql, or postgresql) + */ + public function __construct( + private readonly PhpConfigFileProvider $config, + private readonly Output $output, + string $type + ) { + $this->type = $type; + } + + /** + * Get database connection. + * + * Creates connection on first call, reuses on subsequent calls. + * + * @return PDO Database connection + * @throws PDOException If connection fails + */ + public function connect(): PDO + { + if ($this->pdo !== null) { + return $this->pdo; + } + + $dsn = $this->getDsn(); + $username = $this->getUsername(); + $password = $this->getPassword(); + + $options = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ]; + + $this->pdo = new PDO($dsn, $username, $password, $options); + + return $this->pdo; + } + + /** + * Test if database connection works. + * + * @return bool True if connection successful + */ + public function testConnection(): bool + { + try { + $pdo = $this->connect(); + + // Try a simple query + $stmt = $pdo->query('SELECT 1'); + + return $stmt !== false; + } catch (PDOException $e) { + return false; + } + } + + /** + * Build DSN string from configuration. + * + * @return string PDO DSN string + * @throws Exception If configuration is invalid + */ + public function getDsn(): string + { + if ($this->type === 'sqlite') { + $path = $this->config->getSetting("database.{$this->type}.path"); + return "sqlite:{$path}"; + } + + $name = $this->config->getSetting("database.{$this->type}.name"); + $host = $this->config->getSetting("database.{$this->type}.host"); + $port = $this->config->getSetting("database.{$this->type}.port"); + + if ($this->type === 'mysql') { + return "mysql:host={$host};port={$port};dbname={$name};charset=utf8mb4"; + } + + if ($this->type === 'postgresql') { + return "pgsql:host={$host};port={$port};dbname={$name}"; + } + + throw new Exception("Unsupported database type: {$this->type}"); + } + + /** + * Get username from configuration. + * + * For SQLite, returns null. + * For MySQL/PostgreSQL, tries admin user first, then power, then restricted. + * + * @return string|null Username or null + */ + private function getUsername(): ?string + { + if ($this->type === 'sqlite') { + return null; + } + + // Try admin user first + if ($this->config->hasSetting("database.{$this->type}.user.admin.username")) { + return $this->config->getSetting("database.{$this->type}.user.admin.username"); + } + + // Try power user + if ($this->config->hasSetting("database.{$this->type}.user.power.username")) { + return $this->config->getSetting("database.{$this->type}.user.power.username"); + } + + // Try restricted user + if ($this->config->hasSetting("database.{$this->type}.user.restricted.username")) { + return $this->config->getSetting("database.{$this->type}.user.restricted.username"); + } + + // Fall back to root for connection test + return 'root'; + } + + /** + * Get password from configuration. + * + * For SQLite, returns null. + * For MySQL/PostgreSQL, tries to match username. + * + * @return string|null Password or null + */ + private function getPassword(): ?string + { + if ($this->type === 'sqlite') { + return null; + } + + // Try admin password + if ($this->config->hasSetting("database.{$this->type}.user.admin.password")) { + return $this->config->getSetting("database.{$this->type}.user.admin.password"); + } + + // Try power password + if ($this->config->hasSetting("database.{$this->type}.user.power.password")) { + return $this->config->getSetting("database.{$this->type}.user.power.password"); + } + + // Try restricted password + if ($this->config->hasSetting("database.{$this->type}.user.restricted.password")) { + return $this->config->getSetting("database.{$this->type}.user.restricted.password"); + } + + // Empty password for root (passwordless local connection) + return ''; + } + + /** + * List all tables in database. + * + * @param string $prefix Optional prefix filter + * @return array List of table names + */ + public function listTables(string $prefix = ''): array + { + $pdo = $this->connect(); + + if ($this->type === 'sqlite') { + $sql = "SELECT name FROM sqlite_master WHERE type='table'"; + if ($prefix) { + $sql .= " AND name LIKE :prefix"; + } + $sql .= " ORDER BY name"; + } elseif ($this->type === 'mysql') { + $dbName = $this->config->getSetting("database.{$this->type}.name"); + $sql = "SELECT TABLE_NAME as name FROM information_schema.TABLES WHERE TABLE_SCHEMA = :dbname"; + if ($prefix) { + $sql .= " AND TABLE_NAME LIKE :prefix"; + } + $sql .= " ORDER BY TABLE_NAME"; + } else { // postgresql + $sql = "SELECT tablename as name FROM pg_tables WHERE schemaname = 'public'"; + if ($prefix) { + $sql .= " AND tablename LIKE :prefix"; + } + $sql .= " ORDER BY tablename"; + } + + $stmt = $pdo->prepare($sql); + + if ($this->type === 'mysql') { + $stmt->bindValue(':dbname', $this->config->getSetting("database.{$this->type}.name")); + } + + if ($prefix) { + $stmt->bindValue(':prefix', $prefix . '%'); + } + + $stmt->execute(); + + $tables = []; + while ($row = $stmt->fetch()) { + $tables[] = $row['name']; + } + + return $tables; + } + + /** + * Drop a single table. + * + * @param string $table Table name + * @return bool True if successful + */ + public function dropTable(string $table): bool + { + try { + $pdo = $this->connect(); + + // Validate table name (alphanumeric + underscore only) + if (!preg_match('/^[a-zA-Z0-9_]+$/', $table)) { + throw new Exception("Invalid table name: {$table}"); + } + + // Use identifier quoting for safety + if ($this->type === 'mysql') { + $quotedTable = "`{$table}`"; + } elseif ($this->type === 'postgresql') { + $quotedTable = "\"{$table}\""; + } else { // sqlite + $quotedTable = "\"{$table}\""; + } + + $sql = "DROP TABLE IF EXISTS {$quotedTable}"; + $pdo->exec($sql); + + return true; + } catch (PDOException $e) { + $this->output->warn("Failed to drop table {$table}: " . $e->getMessage()); + return false; + } + } + + /** + * Create database if it doesn't exist. + * + * Only applicable for MySQL/PostgreSQL (SQLite creates on connect). + * + * @param string $name Database name + * @return bool True if successful + */ + public function createDatabase(string $name): bool + { + if ($this->type === 'sqlite') { + // SQLite creates database on connect + return true; + } + + try { + // Connect without database name + $host = $this->config->getSetting("database.{$this->type}.host"); + $port = $this->config->getSetting("database.{$this->type}.port"); + $username = $this->getUsername() ?? 'root'; + $password = $this->getPassword() ?? ''; + + if ($this->type === 'mysql') { + $dsn = "mysql:host={$host};port={$port}"; + } else { // postgresql + $dsn = "pgsql:host={$host};port={$port}"; + } + + $pdo = new PDO($dsn, $username, $password); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + // Validate database name + if (!preg_match('/^[a-zA-Z0-9_]+$/', $name)) { + throw new Exception("Invalid database name: {$name}"); + } + + $sql = "CREATE DATABASE IF NOT EXISTS `{$name}`"; + $pdo->exec($sql); + + return true; + } catch (PDOException $e) { + $this->output->warn("Failed to create database {$name}: " . $e->getMessage()); + return false; + } + } + + /** + * Execute arbitrary SQL query. + * + * @param string $sql SQL query + * @param array $params Parameters for prepared statement + * @return mixed Query result + */ + public function executeQuery(string $sql, array $params = []): mixed + { + $pdo = $this->connect(); + + if (empty($params)) { + return $pdo->exec($sql); + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + return $stmt; + } +} diff --git a/src/Helper/Dependencies.php b/src/Helper/Dependencies.php index 551637b2..ea7fa245 100644 --- a/src/Helper/Dependencies.php +++ b/src/Helper/Dependencies.php @@ -15,6 +15,9 @@ use Horde\Components\Component; use Horde\Components\Component\Dependency as Dependency; use Horde\Components\Output; +use Horde_String; + +use function str_repeat; /** * Components_Helper_Dependencies:: provides a utility that produces a dependency @@ -204,11 +207,11 @@ private function _element( ): void { if (empty($options['short'])) { $this->_output->$color( - \Horde_String::pad( + Horde_String::pad( $this->_listLevel($level) . '|_' . $name, 45 ) - . \Horde_String::pad(' [' . $channel . ']', 20) . ' ' . $info + . Horde_String::pad(' [' . $channel . ']', 20) . ' ' . $info ); } else { $this->_short_list[$key] = ['channel' => $channel, 'name' => $name, 'color' => $color]; @@ -235,8 +238,8 @@ private function _finish(): void array_multisort($channels, $names, $colors); foreach ($names as $key => $name) { $this->_output->{$colors[$key]}( - \Horde_String::pad($name, 28) - . \Horde_String::pad('[' . $channels[$key] . ']', 20) + Horde_String::pad($name, 28) + . Horde_String::pad('[' . $channels[$key] . ']', 20) ); } } @@ -250,6 +253,6 @@ private function _finish(): void */ private function _listLevel($level): string { - return \str_repeat(' ', $level); + return str_repeat(' ', $level); } } diff --git a/src/Helper/DocsOrigin.php b/src/Helper/DocsOrigin.php index 4c202086..95417cd0 100644 --- a/src/Helper/DocsOrigin.php +++ b/src/Helper/DocsOrigin.php @@ -12,6 +12,12 @@ namespace Horde\Components\Helper; use Horde\Components\Output; +use Horde_Http_Client; + +use function file_get_contents; +use function preg_match; +use function preg_match_all; +use function trim; /** * Components_Helper_DocOrigin:: deals with a DOCS_ORIGIN file. @@ -51,7 +57,7 @@ class DocsOrigin public function __construct($docs_origin, /** * The HTTP client for remote access. */ - private readonly \Horde_Http_Client $_client) + private readonly Horde_Http_Client $_client) { if (!is_array($docs_origin)) { $docs_origin = [$docs_origin]; @@ -66,11 +72,11 @@ private function _parse(): array { if ($this->_documents === null) { $this->_documents = []; - $rst = \file_get_contents($this->_docs_origin[0]); - if (\preg_match_all('/^:`([^:]*)`_:(.*)$/m', $rst, $matches)) { + $rst = file_get_contents($this->_docs_origin[0]); + if (preg_match_all('/^:`([^:]*)`_:(.*)$/m', $rst, $matches)) { foreach ($matches[1] as $match) { - if (\preg_match('#^.. _' . $match . ':(.*)$#m', $rst, $url)) { - $this->_documents[$match] = \trim((string) $url[1]); + if (preg_match('#^.. _' . $match . ':(.*)$#m', $rst, $url)) { + $this->_documents[$match] = trim((string) $url[1]); } } } diff --git a/src/Helper/Git.php b/src/Helper/Git.php index 4c570571..2b4b32a7 100644 --- a/src/Helper/Git.php +++ b/src/Helper/Git.php @@ -579,7 +579,7 @@ public function createRemoteTrackingBranch( string $localDir, string $branch, string $remote = 'origin' - ): \Horde\Components\Component\Task\SystemCallResult { + ): SystemCallResult { // git branch -t $branch origin/$branch $cmd = sprintf( '%s branch -t %s %s/%s', @@ -860,6 +860,98 @@ public function describeWithTags(string $localDir): string return trim($result->getOutputString()); } + /** + * Get the SHA of the last commit (HEAD). + * + * @param string $localDir Full path to local git repository + * + * @return string The commit SHA (40-character hex string) + */ + public function getLastCommitSha(string $localDir): string + { + $cmd = $this->gitBin . ' log --format="%H" HEAD^..HEAD'; + $result = $this->execInDirectory($cmd, $localDir); + return trim($result->getOutputString()); + } + + /** + * Get the commit hash for a specific tag. + * + * @param string $localDir Full path to local git repository + * @param string $tagName The tag name to resolve + * + * @return string The commit hash (40-character hex string) + * @throws RuntimeException if tag doesn't exist + */ + public function getTagCommitHash(string $localDir, string $tagName): string + { + $cmd = $this->gitBin . ' rev-list -n 1 ' . escapeshellarg($tagName); + $result = $this->execInDirectory($cmd, $localDir); + + if ($result->getReturnValue() !== 0) { + throw new RuntimeException("Failed to get commit hash for tag {$tagName}: " . $result->getOutputString()); + } + + return trim($result->getOutputString()); + } + + /** + * Get the most recent tag in the repository. + * + * @param string $localDir Full path to local git repository + * + * @return string|null The last tag name, or null if no tags exist + */ + public function getLastTag(string $localDir): ?string + { + $cmd = $this->gitBin . ' describe --tags --abbrev=0 2>/dev/null'; + $result = $this->execInDirectory($cmd, $localDir); + + if ($result->getReturnValue() !== 0) { + return null; + } + + $tag = trim($result->getOutputString()); + return $tag !== '' ? $tag : null; + } + + /** + * Get the commit hash for a specific ref (branch, tag, or commit). + * + * @param string $localDir Full path to local git repository + * @param string $ref The ref to resolve (e.g., 'HEAD', 'main', 'v1.0.0') + * + * @return string The commit hash (40-character hex string) + * @throws RuntimeException if ref doesn't exist + */ + public function getCommitHash(string $localDir, string $ref): string + { + $cmd = $this->gitBin . ' rev-parse ' . escapeshellarg($ref); + $result = $this->execInDirectory($cmd, $localDir); + + if ($result->getReturnValue() !== 0) { + throw new RuntimeException("Failed to resolve ref {$ref}: " . $result->getOutputString()); + } + + return trim($result->getOutputString()); + } + + /** + * Run a git command in a directory and return the result. + * + * This is a convenience method for tasks that need to run custom git commands. + * + * @param string $command The git command (without 'git' prefix) + * @param string $localDir Full path to local git repository + * + * @return SystemCallResult The command result + */ + public function run(string $command, string $localDir): SystemCallResult + { + $cmd = $this->gitBin . ' ' . $command; + return $this->execInDirectory($cmd, $localDir); + } + /** * Run a system call and capture output. * diff --git a/src/Helper/GitHubReleaseCreator.php b/src/Helper/GitHubReleaseCreator.php index f2c8623e..f7518413 100644 --- a/src/Helper/GitHubReleaseCreator.php +++ b/src/Helper/GitHubReleaseCreator.php @@ -14,6 +14,7 @@ use Horde\Http\StreamFactory; use Horde\Http\RequestFactory; use Horde\Http\ResponseFactory; +use Exception; /** * Helper for creating GitHub releases @@ -101,7 +102,7 @@ public function createRelease( $this->output->ok("GitHub release created successfully: {$release->htmlUrl}"); return $release; - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->warn("Failed to create GitHub release: " . $e->getMessage()); return null; } @@ -157,7 +158,7 @@ public function uploadPharAsset( $this->output->ok("PHAR asset uploaded successfully: {$asset->browserDownloadUrl}"); return true; - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->warn("Failed to upload PHAR asset: " . $e->getMessage()); return false; } @@ -200,4 +201,177 @@ public static function formatReleaseNotes(string $notes, string $severity): stri return $header . "\n\n" . trim($notes); } + + /** + * Get a release by tag name + * + * @param string $localDir The local directory path of the component + * @param string $tagName The tag name to look up + * @return object|null The release object or null if not found + */ + public function getReleaseByTag(string $localDir, string $tagName): ?object + { + // Check if this is a GitHub repository + if (!$this->githubChecker->isOnGitHub($localDir)) { + return null; + } + + // Get repository identifier + $repoFullName = $this->githubChecker->getGitHubRepository($localDir); + if (!$repoFullName) { + return null; + } + + // Get GitHub token + $githubToken = $this->githubApiConfig->accessToken; + if ($githubToken === '') { + return null; + } + + try { + // Initialize GitHub API client + $httpClient = new CurlClient(new ResponseFactory(), new StreamFactory(), new Options()); + $requestFactory = new RequestFactory(); + $streamFactory = new StreamFactory(); + $config = new GithubApiConfig(accessToken: $githubToken); + $apiClient = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $repo = GithubRepository::fromFullName($repoFullName); + + // Try to get release by tag + $release = $apiClient->getReleaseByTag($repo, $tagName); + return $release; + } catch (Exception $e) { + // Release not found or API error + return null; + } + } + + /** + * Get an asset from a release by name + * + * @param string $localDir The local directory path of the component + * @param int $releaseId GitHub release ID + * @param string $assetName Asset filename to find + * @return object|null Asset object or null if not found + */ + public function getAssetByName(string $localDir, int $releaseId, string $assetName): ?object + { + // Check if this is a GitHub repository + if (!$this->githubChecker->isOnGitHub($localDir)) { + return null; + } + + // Get repository identifier + $repoFullName = $this->githubChecker->getGitHubRepository($localDir); + if (!$repoFullName) { + return null; + } + + // Get GitHub token + $githubToken = $this->githubApiConfig->accessToken; + if ($githubToken === '') { + return null; + } + + try { + // Initialize GitHub API client + $httpClient = new CurlClient(new ResponseFactory(), new StreamFactory(), new Options()); + $requestFactory = new RequestFactory(); + $streamFactory = new StreamFactory(); + $config = new GithubApiConfig(accessToken: $githubToken); + $apiClient = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $repo = GithubRepository::fromFullName($repoFullName); + + // Get release assets + $release = $apiClient->getRelease($repo, $releaseId); + + if (!$release || !isset($release->assets)) { + return null; + } + + // Find asset by name + foreach ($release->assets as $asset) { + if ($asset->name === $assetName) { + return $asset; + } + } + + return null; + } catch (Exception $e) { + return null; + } + } + + /** + * Upload file as asset to GitHub release + * + * @param string $localDir The local directory path of the component + * @param int $releaseId GitHub release ID + * @param string $filePath Local file path + * @param string $assetName Asset filename (for GitHub) + * @param string $contentType MIME type + * @return object Asset object + * @throws Exception if upload fails + */ + public function uploadAsset( + string $localDir, + int $releaseId, + string $filePath, + string $assetName, + string $contentType = 'application/octet-stream' + ): object { + if (!file_exists($filePath)) { + throw new Exception("File not found: {$filePath}"); + } + + // Check if this is a GitHub repository + if (!$this->githubChecker->isOnGitHub($localDir)) { + throw new Exception('Not a GitHub repository'); + } + + // Get repository identifier + $repoFullName = $this->githubChecker->getGitHubRepository($localDir); + if (!$repoFullName) { + throw new Exception('Could not determine GitHub repository'); + } + + // Get GitHub token + $githubToken = $this->githubApiConfig->accessToken; + if ($githubToken === '') { + throw new Exception('GitHub token not configured'); + } + + // Initialize GitHub API client + $httpClient = new CurlClient(new ResponseFactory(), new StreamFactory(), new Options()); + $requestFactory = new RequestFactory(); + $streamFactory = new StreamFactory(); + $config = new GithubApiConfig(accessToken: $githubToken); + $apiClient = new GithubApiClient($httpClient, $requestFactory, $config, $streamFactory); + + $repo = GithubRepository::fromFullName($repoFullName); + + // Get release to get upload URL + $release = $apiClient->getRelease($repo, $releaseId); + if (!$release) { + throw new Exception("Release not found: {$releaseId}"); + } + + // Read file content + $fileContent = file_get_contents($filePath); + if ($fileContent === false) { + throw new Exception("Failed to read file: {$filePath}"); + } + + // Upload asset + $asset = $apiClient->uploadReleaseAsset( + $release->uploadUrl, + $assetName, + $fileContent, + $contentType + ); + + return $asset; + } } diff --git a/src/Helper/PullRequestManager.php b/src/Helper/PullRequestManager.php index c2bb9530..277eadbf 100644 --- a/src/Helper/PullRequestManager.php +++ b/src/Helper/PullRequestManager.php @@ -29,6 +29,7 @@ use Horde\Http\StreamFactory; use Horde\Http\RequestFactory; use Horde\Http\ResponseFactory; +use Exception; /** * Helper for managing GitHub pull requests @@ -91,7 +92,7 @@ public function initialize(string $localDir): bool $this->repository = GithubRepository::fromFullName($repoFullName); return true; - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to initialize GitHub API client: {$e->getMessage()}"); return false; } @@ -134,7 +135,7 @@ public function listPullRequests(string $state = 'open', string $baseBranch = '' try { return $this->apiClient->listPullRequests($this->repository, $baseBranch, $headRef, $state); - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to list pull requests: {$e->getMessage()}"); return null; } @@ -185,7 +186,7 @@ public function getCheckStatus(\Horde\GithubApiClient\GithubPullRequest $pr): st } return '✓'; - } catch (\Exception $e) { + } catch (Exception $e) { // If we can't get check status, just return unknown return '-'; } @@ -217,7 +218,7 @@ public function mergePullRequest(int $number, ?MergePullRequestParams $params = $this->output->warn("PR #{$number} was not merged: {$result->message}"); return false; } - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to merge PR #{$number}: {$e->getMessage()}"); return false; } @@ -240,7 +241,7 @@ public function closePullRequest(int $number): bool $pr = $this->apiClient->closePullRequest($this->repository, $number); $this->output->ok("Successfully closed PR #{$pr->number}: {$pr->title}"); return true; - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to close PR #{$number}: {$e->getMessage()}"); return false; } @@ -263,7 +264,7 @@ public function reopenPullRequest(int $number): bool $pr = $this->apiClient->reopenPullRequest($this->repository, $number); $this->output->ok("Successfully reopened PR #{$pr->number}: {$pr->title}"); return true; - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to reopen PR #{$number}: {$e->getMessage()}"); return false; } @@ -294,7 +295,7 @@ public function approvePullRequest(int $number, string $body = ''): bool $this->output->info("Review comment: {$body}"); } return true; - } catch (\Exception $e) { + } catch (Exception $e) { $errorMessage = $e->getMessage(); // Special handling for self-approval attempt @@ -336,7 +337,7 @@ public function requestChangesPullRequest(int $number, string $body): bool $this->output->ok("Successfully requested changes on PR #{$number}"); $this->output->info("Review comment: {$body}"); return true; - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to request changes on PR #{$number}: {$e->getMessage()}"); return false; } diff --git a/src/Helper/Root.php b/src/Helper/Root.php index fb7e55d8..3e396a67 100644 --- a/src/Helper/Root.php +++ b/src/Helper/Root.php @@ -16,6 +16,8 @@ use Horde\Components\Exception; use Horde\Components\Output; +use function file_get_contents; + /** * Components_Helper_Root:: handles the root position for a tree of dependencies * and takes the Horde component layout into account. @@ -95,7 +97,7 @@ public function getPackageXml($name): string */ public function getGitIgnore(): string|bool { - return \file_get_contents($this->getRoot() . '/.gitignore'); + return file_get_contents($this->getRoot() . '/.gitignore'); } /** diff --git a/src/Helper/Shell.php b/src/Helper/Shell.php index 05a362c4..e871e0f5 100644 --- a/src/Helper/Shell.php +++ b/src/Helper/Shell.php @@ -19,6 +19,11 @@ use Horde\Components\Output; use Horde\Components\Component\Task\SystemCallResult; +use function exec; +use function shell_exec; +use function sprintf; +use function system; + /** * Universal shell command executor. * @@ -56,7 +61,7 @@ public function exec(string $command, ?string $workingDir = null): SystemCallRes if ($this->pretend) { if ($this->output) { $dir = $workingDir ? " (in {$workingDir})" : ''; - $this->output->info(\sprintf('Would run: "%s"%s', $command, $dir)); + $this->output->info(sprintf('Would run: "%s"%s', $command, $dir)); } return new SystemCallResult([], 0); } @@ -67,7 +72,7 @@ public function exec(string $command, ?string $workingDir = null): SystemCallRes chdir($workingDir); } - \exec($command, $output, $exitCode); + exec($command, $output, $exitCode); if ($oldDir !== null) { chdir($oldDir); @@ -88,7 +93,7 @@ public function system(string $command, ?string $workingDir = null): string if ($this->pretend) { if ($this->output) { $dir = $workingDir ? " (in {$workingDir})" : ''; - $this->output->info(\sprintf('Would run: "%s"%s', $command, $dir)); + $this->output->info(sprintf('Would run: "%s"%s', $command, $dir)); } return ''; } @@ -99,7 +104,7 @@ public function system(string $command, ?string $workingDir = null): string chdir($workingDir); } - $result = \system($command); + $result = system($command); if ($oldDir !== null) { chdir($oldDir); @@ -122,7 +127,7 @@ public function shellExec(string $command, ?string $workingDir = null): string if ($this->pretend) { if ($this->output) { $dir = $workingDir ? " (in {$workingDir})" : ''; - $this->output->info(\sprintf('Would run: "%s"%s', $command, $dir)); + $this->output->info(sprintf('Would run: "%s"%s', $command, $dir)); } return ''; } @@ -133,7 +138,7 @@ public function shellExec(string $command, ?string $workingDir = null): string chdir($workingDir); } - $result = \shell_exec($command); + $result = shell_exec($command); if ($oldDir !== null) { chdir($oldDir); diff --git a/src/Helper/Template.php b/src/Helper/Template.php index a5919e64..e528b1ad 100644 --- a/src/Helper/Template.php +++ b/src/Helper/Template.php @@ -11,6 +11,12 @@ namespace Horde\Components\Helper; +use function basename; +use function dirname; +use function file_put_contents; +use function ob_get_clean; +use function ob_start; + /** * Components_Helper_Template:: converts a template into a target file. * @@ -44,10 +50,10 @@ public function write(array $parameters = []): void foreach ($parameters as $key => $value) { ${$key} = $value; } - $tdir = \dirname($this->_target); - $target = \basename($this->_target); - \ob_start(); + $tdir = dirname($this->_target); + $target = basename($this->_target); + ob_start(); include $this->_source; - \file_put_contents($tdir . DIRECTORY_SEPARATOR . $target, \ob_get_clean()); + file_put_contents($tdir . DIRECTORY_SEPARATOR . $target, ob_get_clean()); } } diff --git a/src/Helper/Templates/Directory.php b/src/Helper/Templates/Directory.php index 8dd5e0e1..2bb6bf92 100644 --- a/src/Helper/Templates/Directory.php +++ b/src/Helper/Templates/Directory.php @@ -14,6 +14,8 @@ use Horde\Components\Exception; use Horde\Components\Helper\Templates; +use DirectoryIterator; +use IteratorIterator; /** * Components_Helper_Templatesdirectory:: converts template files from a @@ -62,7 +64,7 @@ public function write(array $parameters = []): void mkdir($this->_target, 0o777, true); } foreach ( - new \IteratorIterator(new \DirectoryIterator($this->_source)) as $file + new IteratorIterator(new DirectoryIterator($this->_source)) as $file ) { if ($file->isFile()) { $this->writeSourceToTarget( diff --git a/src/Helper/Templates/RecursiveDirectory.php b/src/Helper/Templates/RecursiveDirectory.php index ce2454d1..7e7151ee 100644 --- a/src/Helper/Templates/RecursiveDirectory.php +++ b/src/Helper/Templates/RecursiveDirectory.php @@ -14,6 +14,8 @@ use Horde\Components\Exception; use Horde\Components\Helper\Templates; +use DirectoryIterator; +use IteratorIterator; /** * Components_Helper_Templates_RecursiveDirectory:: converts template files @@ -62,7 +64,7 @@ public function write(array $parameters = []): void mkdir($this->_target, 0o777, true); } foreach ( - new \IteratorIterator(new \DirectoryIterator($this->_source)) as $file + new IteratorIterator(new DirectoryIterator($this->_source)) as $file ) { if ($file->isFile()) { $this->writeSourceToTarget( diff --git a/src/Helper/Version.php b/src/Helper/Version.php index d4cf2205..ff88bc74 100644 --- a/src/Helper/Version.php +++ b/src/Helper/Version.php @@ -16,6 +16,17 @@ namespace Horde\Components\Helper; use Horde\Components\Exception; +use stdClass; + +use function array_map; +use function array_pop; +use function array_walk; +use function explode; +use function preg_replace; +use function strlen; +use function strpos; +use function usort; +use function version_compare; /** * Converts between different version schemes. @@ -510,11 +521,11 @@ public static function pearToTicketDescription($version): string * * @throws Exception on invalid version string. */ - public static function parsePearVersion($version): \stdClass + public static function parsePearVersion($version): stdClass { \preg_match('/([.\d]+)(.*)/', $version, $matches); - $result = new \stdClass(); + $result = new stdClass(); $result->version = $matches[1]; $result->description = ''; $result->subversion = null; @@ -533,7 +544,7 @@ public static function parsePearVersion($version): \stdClass } else { $result->description = 'Final'; } - $vcomp = \explode('.', (string) $result->version); + $vcomp = explode('.', (string) $result->version); if (\count($vcomp) != 3) { throw new Exception('A version number must have 3 parts.'); } @@ -689,9 +700,9 @@ public static function composerToPear($version): array // Massage versions by splitting at '||', checking for and removing // leading '^', and sorting. - $versions = \explode('||', $version); - $versions = \array_map('trim', $versions); - \array_walk( + $versions = explode('||', $version); + $versions = array_map('trim', $versions); + array_walk( $versions, function ($v) use ($version, $versions) { if ($v[0] != '^' @@ -703,14 +714,14 @@ function ($v) use ($version, $versions) { } } ); - \usort( + usort( $versions, - fn($a, $b) => \version_compare(\ltrim((string) $a, '^'), \ltrim((string) $b, '^')) + fn($a, $b) => version_compare(\ltrim((string) $a, '^'), \ltrim((string) $b, '^')) ); $constraints = []; if ($versions[0][0] == '^') { - $constraints['min'] = \preg_replace( + $constraints['min'] = preg_replace( '/^\^(\d+\.\d+\.\d+).*/', '$1', $versions[0] . '.0.0' @@ -719,8 +730,8 @@ function ($v) use ($version, $versions) { $constraints['min'] = $constraints['max'] = $versions[0]; return $constraints; } - $max = \array_pop($versions); - $max = \substr($max, 1, \strpos($max, '.') ?: \strlen($max)) + 1; + $max = array_pop($versions); + $max = \substr($max, 1, strpos($max, '.') ?: strlen($max)) + 1; $max .= '.0.0alpha1'; $constraints['max'] = $constraints['exclude'] = $max; diff --git a/src/Helper/Website.php b/src/Helper/Website.php index 29fa5673..9749f52f 100644 --- a/src/Helper/Website.php +++ b/src/Helper/Website.php @@ -17,6 +17,9 @@ use Horde\Components\Component; use Horde\Components\Exception; use Horde\Components\Output; +use Horde_Util; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; /** * This class is a helper for a horde-web git repository checkout. @@ -61,7 +64,7 @@ public function update(Component $component, $options): void throw new Exception('"--html-generator" MUST be set for this action!'); } - $tmp_dir = \Horde_Util::createTempDir(); + $tmp_dir = Horde_Util::createTempDir(); $archive = $component->placeArchive( $tmp_dir, ['logger' => $this->_output] @@ -211,7 +214,7 @@ private function _identifyDocFiles($path) if (!is_dir($path)) { return $doc_files; } - foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path)) as $file) { + foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)) as $file) { if ($file->isFile() && preg_match('/[A-Z_]+/', (string) $file->getFilename()) && !preg_match('/\.(html|php)$/', (string) $file->getFilename()) diff --git a/src/Module/Base.php b/src/Module/Base.php index 67b74c88..6fee313f 100644 --- a/src/Module/Base.php +++ b/src/Module/Base.php @@ -19,6 +19,7 @@ use Horde\Components\Dependencies; use Horde\Injector\Injector; use Horde\Components\Module; +use Horde_Cli_Modular_ModuleUsage; /** * Components_Module_Base:: provides core functionality for the @@ -34,7 +35,7 @@ * @author Gunnar Wrobel * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ -abstract class Base implements Module, \Horde_Cli_Modular_ModuleUsage +abstract class Base implements Module, Horde_Cli_Modular_ModuleUsage { /** * Constructor. @@ -65,6 +66,20 @@ public function getUsage(): string return ''; } + /** + * Get a short one-line description for command listings. + * + * This is used in the main help output to keep it concise. + * Override this in subclasses to provide a brief description. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + // Default: return the title (subclasses should override) + return $this->getTitle() . ' command'; + } + /** * Get a set of base options that this module adds to the CLI argument * parser. diff --git a/src/Module/Change.php b/src/Module/Change.php index 5c721d56..ba6212a1 100644 --- a/src/Module/Change.php +++ b/src/Module/Change.php @@ -79,6 +79,16 @@ public function getUsage(): string return 'Add a change log entry.'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Add a changelog entry'; + } + /** * Return the action arguments supported by this module. * diff --git a/src/Module/Ci.php b/src/Module/Ci.php index 4217fc63..55b01f29 100644 --- a/src/Module/Ci.php +++ b/src/Module/Ci.php @@ -30,6 +30,7 @@ use Horde\Components\Ci\Config\CiConfig; use Horde\Components\Ci\Run\RunCommand; use Horde\Components\Ci\Run\ResultCollector; +use Phar; /** * Components_Module_Ci:: manages CI setup and execution for components. @@ -124,6 +125,16 @@ public function getUsage(): string return 'Manage CI setup and execution.'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Manage continuous integration'; + } + /** * Return the action arguments supported by this module. * @@ -495,9 +506,9 @@ private function handleRun(array $options, Output $output): bool // Determine horde-components path // Check if we're running from a phar - if (strlen(\Phar::running()) > 0) { + if (strlen(Phar::running()) > 0) { // We're inside a phar - use the phar path - $componentsPath = \Phar::running(false); + $componentsPath = Phar::running(false); } else { // Normal file system - use relative path to bin/horde-components $componentsPath = realpath(__DIR__ . '/../../bin/horde-components'); diff --git a/src/Module/Composer.php b/src/Module/Composer.php index 7c38ea71..d43dd8c3 100644 --- a/src/Module/Composer.php +++ b/src/Module/Composer.php @@ -91,6 +91,16 @@ public function getUsage(): string return 'Create config file for PHP Composer.'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Generate composer.json files'; + } + /** * Return the action arguments supported by this module. * diff --git a/src/Module/Config.php b/src/Module/Config.php index 0df82378..b58155de 100644 --- a/src/Module/Config.php +++ b/src/Module/Config.php @@ -70,6 +70,16 @@ public function getUsage(): string return 'configure the tool'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Configure horde-components settings'; + } + /** * Return the action arguments supported by this module. * @@ -113,9 +123,11 @@ public function getHelp($action): string The file is created automatically when you first write a value. COMMON CONFIGURATION KEYS: - checkout.dir Directory where Horde repositories are checked out - Default: ~/git/horde or /srv/git/horde - Example: /home/user/projects/horde + checkout.dir Directory containing vendor subdirectories with repositories + Components are located at: checkout.dir/vendor/component + Default: ~/git or /srv/git + Example: /home/user/projects + (Components would be at: /home/user/projects/horde/ActiveSync, etc.) repo.org GitHub organization or user name for repositories Default: horde @@ -144,8 +156,8 @@ public function getHelp($action): string horde-components config checkout.dir horde-components config repo.org - # Set a configuration value - horde-components config checkout.dir /home/user/horde + # Set checkout directory (parent of vendor directories) + horde-components config checkout.dir /home/user/git horde-components config repo.org mycompany horde-components config scm.domain https://github.example.com horde-components config github.token ghp_your_token_here diff --git a/src/Module/DatabaseModule.php b/src/Module/DatabaseModule.php new file mode 100644 index 00000000..753ce611 --- /dev/null +++ b/src/Module/DatabaseModule.php @@ -0,0 +1,296 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Module; + +use Horde\Components\Component; +use Horde\Components\Output; +use Horde\Components\Runner\DatabaseRunner; +use Horde\Components\ConfigProvider\PhpConfigFileProvider; +use Horde_Cli; + +/** + * Database Module - Manage database installation and configuration + * + * Provides interactive commands for: + * - Installing database software and PHP drivers + * - Configuring database connections + * - Managing database users and privileges + * - Cleaning up test tables + * + * Copyright 2026 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 DatabaseModule extends Base +{ + public function getOptionGroupTitle(): string + { + return 'Database'; + } + + public function getOptionGroupDescription(): string + { + return 'Manage database installation, configuration, and lifecycle'; + } + + public function getOptionGroupOptions(): array + { + return [ + new \Horde\Argv\Option( + '--db-type', + [ + 'action' => 'store', + 'help' => 'Database type: sqlite, mysql, or postgresql', + ] + ), + new \Horde\Argv\Option( + '--user-type', + [ + 'action' => 'store', + 'help' => 'User privilege level: admin, power, or restricted', + ] + ), + new \Horde\Argv\Option( + '--auto-password', + [ + 'action' => 'store_true', + 'help' => 'Automatically generate random password (skip prompt)', + ] + ), + ]; + } + + /** + * Get the usage title for this module. + * + * @return string The title. + */ + public function getTitle(): string + { + return 'database'; + } + + /** + * Get the usage description for this module. + * + * @return string The description. + */ + public function getUsage(): string + { + return <<<'HELP' + Manage database software installation, configuration, and lifecycle for testing. + + This module helps set up and manage databases for CI pipelines and local development. + Supports SQLite, MySQL, and PostgreSQL with interactive configuration. + + USAGE: + horde-components database + horde-components db + + SUBCOMMANDS: + install Install database software and PHP drivers + Generates an idempotent Ubuntu 24.04 install script + Creates database users from config if configured + Executes script if running with sudo privileges + + configure Configure database connection parameters + Interactive prompts for type, name, host, port, prefix + Saves configuration to ~/.config/horde/components.php + Tests connection after configuration + + user Configure database user credentials + Three privilege levels: admin, power, restricted + Generates random passwords if not provided + Saves credentials to config (used by install) + + drop Drop all tables matching configured prefix + Lists tables before dropping + Requires explicit confirmation + Useful for cleaning up after tests + + WORKFLOW - Fresh Setup: + # 1. Configure database connection + horde-components database configure + + # 2. Configure users (credentials saved to config) + horde-components database user + + # 3. Install database software and create users + horde-components database install + + # 4. Run your tests... + + # 5. Clean up after tests + horde-components database drop + + WORKFLOW - Existing Database: + # 1. Configure connection to existing database + horde-components database configure + + # 2. Use existing users (no install needed) + + # 3. Run your tests... + + # 4. Clean up only test tables + horde-components database drop + + CONFIGURATION: + Configuration is stored in: ~/.config/horde/components.php + + Connection parameters: + database.type Database type (sqlite, mysql, postgresql) + database.name Database name + database.host Host (not for SQLite) + database.port Port (not for SQLite) + database.prefix Table prefix for test tables + database.path File path (SQLite only) + + User credentials (three levels): + database.user.admin.username Admin user (full privileges) + database.user.admin.password Admin password + database.user.power.username Power user (DDL + DML) + database.user.power.password Power password + database.user.restricted.username Restricted user (DML only) + database.user.restricted.password Restricted password + + EXAMPLES: + # SQLite setup (simplest) + horde-components database configure + # Choose: sqlite + # Database file: build/horde_ci.sqlite + # Prefix: test_ + + # MySQL setup with users + horde-components database configure + # Choose: mysql + # Name: horde_ci + # Host: localhost + # Port: 3306 + # Prefix: test_components_ + + horde-components database user + # Choose privilege level: admin + # Username: ci_admin + # Password: (empty for random) + + horde-components database install + # Generates script, installs MySQL, creates users + + PRIVILEGE LEVELS: + admin Full database control (ALL PRIVILEGES) + Use for: Database setup, migrations, schema changes + + power Schema modification allowed (DDL + DML) + Privileges: SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER + Use for: Tests that create/drop tables + + restricted Data access only (DML) + Privileges: SELECT, INSERT, UPDATE, DELETE + Use for: Tests that only read/write data + + SECURITY NOTE: + Passwords are stored in plain text in the config file. + File permissions: 0600 (readable only by you) + This is acceptable for development/CI tools. + + SEE ALSO: + horde-components config View/edit configuration + horde-components ci setup Setup CI environment + HELP; + } + + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Manage database setup for testing'; + } + + /** + * Return the action arguments supported by this module. + * + * @return array A list of supported action arguments. + */ + public function getActions(): array + { + return ['database', 'db']; + } + + /** + * Return the help text for the specified action. + * + * @param string $action The action. + * + * @return string The help text. + */ + public function getHelp($action): string + { + return $this->getUsage(); + } + + /** + * Return the options that should be explained in the context help. + * + * @return array A list of option help texts. + */ + public function getContextOptionHelp(): array + { + return []; + } + + /** + * Determine if this module should act. Run all required actions if it has + * been instructed to do so. + * + * @param array $options CLI options + * @param array $arguments CLI arguments + * @param Component|null $component The selected component (if any) + * + * @return bool True if the module performed some action. + */ + public function handle(array $options, array $arguments, ?Component $component = null): bool + { + // Check if this module should handle the request + if (empty($arguments[0]) || !in_array($arguments[0], ['database', 'db'])) { + return false; + } + + // Get dependencies + $output = $this->dependencies->get(Output::class); + $configFile = $this->dependencies->get(PhpConfigFileProvider::class); + $cli = $this->dependencies->get(Horde_Cli::class); + + // Create and run runner + $runner = new DatabaseRunner( + $cli, + $output, + $configFile + ); + + $runner->run($arguments, $options); + + return true; + } +} diff --git a/src/Module/Git.php b/src/Module/Git.php index 930b3ae0..e7705d59 100644 --- a/src/Module/Git.php +++ b/src/Module/Git.php @@ -95,6 +95,16 @@ public function getUsage(): string return 'Run git workflows'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Run git workflows'; + } + /** * Return the action arguments supported by this module. * diff --git a/src/Module/Help.php b/src/Module/Help.php index 4588ed1c..e8a113dc 100644 --- a/src/Module/Help.php +++ b/src/Module/Help.php @@ -82,6 +82,16 @@ public function getUsage(): string return 'Provide information about the specified ACTION.'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Show help for commands'; + } + /** * Return the action arguments supported by this module. * @@ -148,8 +158,86 @@ public function handleWithAction(string $action) public function handleWithoutAction(): bool { - $modular = $this->dependencies->get(ModularCli::class); - $modular->getParser()->printUsage(); + // Show command list when "horde-components help" is invoked + $this->showCommandList(); return true; } + + /** + * Show brief help (when horde-components is run with no arguments). + * + * This is shown via Components.php, not directly by this module. + */ + public function showBriefHelp(): void + { + $green = "\033[32m"; + $reset = "\033[0m"; + + echo "Horde Components - Development tool for Horde Framework\n\n"; + echo "USAGE:\n"; + echo " horde-components [options] [arguments]\n"; + echo " horde-components {$green}help{$reset} \n\n"; + echo "COMMON COMMANDS:\n"; + echo " {$green}release{$reset} Release a component\n"; + echo " {$green}ci{$reset} Manage continuous integration\n"; + echo " {$green}database{$reset}|{$green}db{$reset} Manage database setup for testing\n"; + echo " {$green}qc{$reset} Run quality checks\n\n"; + echo " {$green}config{$reset} Configure horde-components\n"; + echo " {$green}status{$reset} Show component and environment status\n\n"; + echo "MORE COMMANDS:\n"; + echo " {$green}changed{$reset}, {$green}composer{$reset}, {$green}git{$reset}, {$green}init{$reset}, {$green}install{$reset}, {$green}package{$reset}, {$green}pullrequest{$reset}|{$green}pr{$reset}, {$green}version{$reset}, {$green}web{$reset}\n\n"; + echo " Use '{$green}horde-components help{$reset}' to see all commands with descriptions\n"; + echo " Use '{$green}horde-components help {$reset}' for detailed command help\n\n"; + echo "GETTING STARTED:\n"; + echo " {$green}horde-components help{$reset} Show all available commands\n"; + echo " {$green}horde-components status{$reset} Check your environment\n"; + echo " {$green}horde-components config{$reset} Configure the tool\n"; + } + + /** + * Show command list with short descriptions (horde-components help). + */ + public function showCommandList(): void + { + echo "Horde Components - Available Commands\n\n"; + + $categories = [ + 'RELEASE & VERSIONING' => ['release', 'version', 'changed'], + 'CI & TESTING' => ['ci', 'database', 'qc'], + 'GITHUB INTEGRATION' => ['github', 'pullrequest'], + 'PROJECT SETUP' => ['init', 'install', 'config'], + 'BUILD & PACKAGING' => ['composer', 'package'], + 'UTILITIES' => ['git', 'status', 'web'], + ]; + + // Build module lookup by action + $modular = $this->dependencies->get(ModularCli::class); + $modulesByAction = []; + foreach ($modular->getModules() as $module) { + foreach ($module->getActions() as $action) { + if ($action !== 'help') { // Skip self-reference + $modulesByAction[$action] = $module; + } + } + } + + // Display by category + foreach ($categories as $categoryName => $actions) { + echo "$categoryName:\n"; + foreach ($actions as $action) { + if (isset($modulesByAction[$action])) { + $module = $modulesByAction[$action]; + $description = $module->getShortDescription(); + printf(" %-20s %s\n", $action, $description); + } + } + echo "\n"; + } + + echo "Use 'horde-components help ' for detailed help on a specific command.\n\n"; + echo "EXAMPLES:\n"; + echo " horde-components help release See release command help\n"; + echo " horde-components help ci See CI command help\n"; + echo " horde-components ci help Alternative syntax\n"; + } } diff --git a/src/Module/Init.php b/src/Module/Init.php index 5845041d..1531cafb 100644 --- a/src/Module/Init.php +++ b/src/Module/Init.php @@ -105,6 +105,16 @@ public function getUsage(): string return 'Scaffold new components from templates'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Scaffold new components from templates'; + } + /** * Return the action arguments supported by this module. * @@ -155,7 +165,7 @@ public function getContextOptionHelp(): array '--email' => 'The author\'s email address', '--description' => 'Short component description', '--use-license' => 'License identifier (default: LGPL-2.1)', - '--force-overwrite' => 'Overwrite existing files' + '--force-overwrite' => 'Overwrite existing files', ]; } diff --git a/src/Module/InstallModule.php b/src/Module/InstallModule.php index df4a1e45..4ca7f246 100644 --- a/src/Module/InstallModule.php +++ b/src/Module/InstallModule.php @@ -17,9 +17,10 @@ use Horde\Components\Component; use Horde\Components\Dependencies; -use Horde\Components\Component\ComponentDirectory; +use Horde\Components\Composer\InstallationDirectory; +use Horde\Components\Output; use Horde\Components\Runner\InstallRunner; -use Horde\Components\RuntimeContext\CurrentWorkingDirectory; +use Horde\Components\RuntimeContext\GitCheckoutDirectory; /** * InstallModule:: Setup a horde installation from a git checkout @@ -68,7 +69,72 @@ public function getTitle(): string */ public function getUsage(): string { - return 'install'; + return <<<'HELP' + Install Horde from a git checkout into a web-accessible directory. + + This command sets up a complete Horde development environment by: + 1. Copying the horde/bundle component from your git checkout + 2. Configuring composer.json to use local path repositories for all Horde components + 3. Setting up the installation directory ready for composer install + + USAGE: + horde-components install + + CONFIGURATION: + This command uses two configuration settings: + + checkout.dir Your Horde git checkout directory (parent of vendor directories) + Components are located at: checkout.dir/horde/component + (default: ~/git or /srv/git) + Set with: horde-components config checkout.dir /path/to/git + + install.dir Target installation directory (web tree) + (default: ~/www/horde-dev or /srv/www/horde-dev) + Set with: horde-components config install.dir /srv/www/horde + Or set HORDE_INSTALL_DIR environment variable + + PREREQUISITES: + 1. Complete git checkout with Horde components + Run: horde-components github clone-org horde + + 2. Empty or non-existent installation directory + The command will create it if needed + + WORKFLOW: + 1. Copies horde/bundle from git checkout to installation directory + 2. Adds all Horde components as composer path repositories + 3. Configures composer to prefer stable packages + 4. You then run: composer install (in the installation directory) + + EXAMPLE: + # Set up directories + horde-components config checkout.dir ~/git + horde-components config install.dir /srv/www/horde-dev + + # Clone all Horde repositories (if not done yet) + horde-components github clone-org horde + + # Install Horde + horde-components install + + # Complete the installation + cd /srv/www/horde-dev + composer install + + SEE ALSO: + horde-components github clone-org Clone all Horde repositories + horde-components status Check directory configuration + HELP; + } + + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Install Horde from git checkout to web directory'; } /** @@ -90,7 +156,7 @@ public function getActions(): array */ public function getHelp($action): string { - return 'horde-components install'; + return $this->getUsage(); } /** @@ -117,12 +183,20 @@ public function handle(array $options, array $arguments, ?Component $component = { if (!empty($options['install']) || (isset($arguments[0]) && $arguments[0] == 'install')) { - $componentDirectory = new ComponentDirectory($options['working_dir'] ?? new CurrentWorkingDirectory()); - $component = $this->dependencies - ->getComponentFactory() - ->createSource($componentDirectory); - // @todo: InstallRunner still needs Config, needs refactoring - // For now, this module is not fully migrated + + // Get dependencies + $output = $this->dependencies->get(Output::class); + $gitCheckoutDir = $this->dependencies->get(GitCheckoutDirectory::class); + $installDir = $this->dependencies->get(InstallationDirectory::class); + + // Instantiate and run runner + $runner = new InstallRunner( + $gitCheckoutDir, + $installDir, + $output + ); + $runner->run(); + return true; } return false; diff --git a/src/Module/Package.php b/src/Module/Package.php index 5eeec216..b9552305 100644 --- a/src/Module/Package.php +++ b/src/Module/Package.php @@ -83,6 +83,16 @@ public function getUsage(): string return 'Check Package Info'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Check package information'; + } + /** * Return the action arguments supported by this module. * diff --git a/src/Module/Pullrequest.php b/src/Module/Pullrequest.php index 6c577384..35dedb50 100644 --- a/src/Module/Pullrequest.php +++ b/src/Module/Pullrequest.php @@ -95,6 +95,16 @@ public function getUsage(): string return 'Manage GitHub pull requests'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Manage pull requests'; + } + /** * Return the action arguments supported by this module. * diff --git a/src/Module/Qc.php b/src/Module/Qc.php index da231c84..1a4bb2da 100644 --- a/src/Module/Qc.php +++ b/src/Module/Qc.php @@ -66,6 +66,13 @@ public function getOptionGroupOptions(): array 'help' => 'Directory containing QC tool binaries (PHPUnit, PHPStan, etc.)', ] ), + new \Horde\Argv\Option( + '--prefer-config-from', + [ + 'action' => 'store', + 'help' => 'Config file preference: "tool" (horde-components), "uut" (component being tested), or path to specific config. Applies to PHP CS Fixer and PHPStan.', + ] + ), ]; } @@ -89,6 +96,16 @@ public function getUsage(): string return 'Check the package quality.'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Run quality checks (linter, PHPStan, tests)'; + } + /** * Return the action arguments supported by this module. * diff --git a/src/Module/Release.php b/src/Module/Release.php index 70b01a3f..72d7da14 100644 --- a/src/Module/Release.php +++ b/src/Module/Release.php @@ -117,6 +117,16 @@ public function getUsage(): string return 'Releases a component.'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Release a component (h5 or h6 workflow)'; + } + /** * Return the action arguments supported by this module. * diff --git a/src/Module/Status.php b/src/Module/Status.php index 80010978..62919f4f 100644 --- a/src/Module/Status.php +++ b/src/Module/Status.php @@ -25,6 +25,7 @@ use Horde\Components\RuntimeContext\CurrentWorkingDirectory; use Horde\Components\RuntimeContext\GitCheckoutDirectory; use Horde\Components\Runner\Status as RunnerStatus; +use ReflectionClass; /** * Components_Module_Change:: records a change log entry. @@ -76,6 +77,16 @@ public function getUsage(): string return 'Show status'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Show component and environment status'; + } + /** * Return the action arguments supported by this module. * @@ -149,11 +160,13 @@ public function getHelp($action): string CONFIGURATION DEPENDENCIES: The status command respects these configuration settings: - checkout.dir The git checkout directory to check - (default: ~/git/horde or /srv/git/horde) + checkout.dir The git checkout directory to check (parent of vendor directories) + Components are located at: checkout.dir/horde/component + (default: ~/git or /srv/git) install.dir The Horde installation directory to check - (default: derived from runtime context) + (default: ~/www/horde-dev or /srv/www/horde-dev) + Can also use HORDE_INSTALL_DIR environment variable COMMON WARNINGS AND FIXES: \"Config file does not exist or is not readable\" @@ -165,7 +178,7 @@ public function getHelp($action): string \"Git Tree dir does not exist or is not readable\" Fix: Create the directory or update checkout.dir config setting mkdir -p ~/git/horde - horde-components config checkout.dir ~/git/horde + horde-components config checkout.dir ~/git \"Install dir does not exist or is not readable\" Fix: Create a Horde installation using composer @@ -220,7 +233,7 @@ public function handle(array $options, array $arguments, ?Component $component = // Get config file path from PhpConfigFileProvider $phpConfigProvider = $this->dependencies->get(PhpConfigFileProvider::class); // Access the private location property via reflection - $reflection = new \ReflectionClass($phpConfigProvider); + $reflection = new ReflectionClass($phpConfigProvider); $locationProperty = $reflection->getProperty('location'); $locationProperty->setAccessible(true); $configFilePath = $locationProperty->getValue($phpConfigProvider); diff --git a/src/Module/Version.php b/src/Module/Version.php index bed17871..544868ed 100644 --- a/src/Module/Version.php +++ b/src/Module/Version.php @@ -24,6 +24,7 @@ use Horde\Components\Helper\Git; use Horde\Version\RelaxedSemanticVersion; use Horde\Version\InvalidVersionException; +use Phar; /** * Display version information for horde-components tool and UUT. @@ -85,6 +86,16 @@ public function getUsage(): string return 'Display version information'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Display version information'; + } + /** * Return the action arguments supported by this module. * @@ -183,11 +194,11 @@ public function handle(array $options, array $arguments, ?Component $component = private function getToolVersion(): string { // Check if running from PHAR - if (class_exists('Phar') && \Phar::running(false) !== '') { + if (class_exists('Phar') && Phar::running(false) !== '') { // Try to get git version from PHAR manifest - $pharPath = \Phar::running(false); + $pharPath = Phar::running(false); try { - $phar = new \Phar($pharPath); + $phar = new Phar($pharPath); $meta = $phar->getMetadata(); if (is_array($meta) && isset($meta['version'])) { return $meta['version']; diff --git a/src/Module/Webdocs.php b/src/Module/Webdocs.php index 38ed5d7b..2d729126 100644 --- a/src/Module/Webdocs.php +++ b/src/Module/Webdocs.php @@ -16,6 +16,7 @@ namespace Horde\Components\Module; use Horde\Components\Component; +use Horde_Argv_IndentedHelpFormatter; /** * Webdocs:: generates the www.horde.org data for a component. @@ -89,6 +90,16 @@ public function getUsage(): string return 'Generate documentation for www.horde.org.'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Generate dev.horde.org website'; + } + /** * Return the action arguments supported by this module. * @@ -108,7 +119,7 @@ public function getActions(): array */ public function getHelp($action): string { - $formatter = new \Horde_Argv_IndentedHelpFormatter(); + $formatter = new Horde_Argv_IndentedHelpFormatter(); return 'This module generates the required set of data to publish information about this component on www.horde.org. The operation will only work with an already relased package! Make sure you enter the name of the package on the PEAR server rather than using a local path and ensure you added the "' . $formatter->highlightOption('--allow-remote') . '" flag as well.'; } diff --git a/src/Module/Website.php b/src/Module/Website.php index 4b9c2527..3439f4c9 100644 --- a/src/Module/Website.php +++ b/src/Module/Website.php @@ -112,6 +112,16 @@ public function getUsage(): string return 'web - Generate dev.horde.org website'; } + /** + * Get a short one-line description for command listings. + * + * @return string The short description. + */ + public function getShortDescription(): string + { + return 'Generate dev.horde.org website'; + } + public function getActions(): array { return ['web', 'web catalog']; @@ -129,9 +139,13 @@ public function getHelp($action): string Options: --web-input Webhook JSON directory (default: data/webhooks) + Config: devsite.input_dir --web-output Output directory (default: build/dev.horde.org) + Config: devsite.output_dir --web-templates Templates directory (default: data/website) + Config: devsite.template_dir --web-components Component catalog JSON (default: data/website/components.json) + Config: devsite.components Examples: horde-components web @@ -145,14 +159,20 @@ public function getHelp($action): string Options: --web-components Catalog output file (default: data/website/components.json) + Config: devsite.components --web-org GitHub organization (default: horde) + Config: repo.org (shared) or devsite.org --web-git-dir Local git repos for version info + Config: checkout.dir (shared) or devsite.git_dir --web-token GitHub API token (or set GITHUB_TOKEN env var) + Config: github.token (shared) or devsite.token Examples: horde-components web catalog - horde-components web catalog --web-org horde --web-git-dir ~/git/horde + horde-components web catalog --web-org horde --web-git-dir ~/git GITHUB_TOKEN=ghp_xxx horde-components web catalog + + Note: CLI options --web-* map to devsite.* config keys for consistency. '; } diff --git a/src/Output.php b/src/Output.php index aaf7162c..393b6cf9 100644 --- a/src/Output.php +++ b/src/Output.php @@ -14,6 +14,7 @@ use Horde\Components\Output\Presenter; use Horde\Components\Output\PresenterFactory; +use Horde_Cli; /** * Components_Output:: handles output from the components application. @@ -54,10 +55,10 @@ class Output /** * Constructor. * - * @param \Horde_Cli $_cli The CLI handler. + * @param Horde_Cli $_cli The CLI handler. * @param array $options The configuration for the current job. */ - public function __construct(private readonly \Horde_Cli $_cli, $options) + public function __construct(private readonly Horde_Cli $_cli, $options) { $this->_verbose = !empty($options['verbose']); $this->_quiet = !empty($options['quiet']); diff --git a/src/Pear/Environment.php b/src/Pear/Environment.php index 2dee5db1..35104625 100644 --- a/src/Pear/Environment.php +++ b/src/Pear/Environment.php @@ -14,6 +14,13 @@ use Horde\Components\Exception; use Horde\Components\Exception\Pear as ExceptionPear; use Horde\Components\Output; +use Horde_Pear_Remote; +use PEAR_ChannelFile; +use PEAR_Command_Channels; +use PEAR_Command_Install; +use PEAR_Config; +use PEAR_Frontend_CLI; +use PEAR_Registry; /** * PearEnvironment:: handles a specific PEAR environment. @@ -33,7 +40,7 @@ class Environment /** * The factory for PEAR class instances. */ - private ?\Horde\Components\Pear\Factory $_factory = null; + private ?Factory $_factory = null; /** * The base directory for the PEAR install location. @@ -203,7 +210,7 @@ public function createPearConfig(): void } ob_start(); $config = Exception_Pear::catchError( - \PEAR_Config::singleton($this->_config_file, '#no#system#config#', false) + PEAR_Config::singleton($this->_config_file, '#no#system#config#', false) ); $root = dirname($this->_config_file); $config->noRegistry(); @@ -220,7 +227,7 @@ public function createPearConfig(): void $config->set('bin_dir', "$root/pear"); $config->writeConfigFile(); $config->_noRegistry = false; - $config->_registry['default'] = new \PEAR_Registry("$root/pear/php"); + $config->_registry['default'] = new PEAR_Registry("$root/pear/php"); $config->_noRegistry = true; if (!file_exists("$root/pear")) { mkdir("$root/pear/php", 0o777, true); @@ -235,7 +242,7 @@ public function createPearConfig(): void } /** - * @return \PEAR_Config + * @return PEAR_Config * @throws Exception * @throws ExceptionPear */ @@ -245,8 +252,8 @@ public function getPearConfig() $GLOBALS['_PEAR_Config_instance'] = false; } if (empty($this->_config_file)) { - /** @var \PEAR_Config $config */ - $config = \PEAR_Config::singleton(); + /** @var PEAR_Config $config */ + $config = PEAR_Config::singleton(); if (!$config->validConfiguration()) { throw new Exception( 'Set the path to the PEAR environment first!' @@ -258,7 +265,7 @@ public function getPearConfig() $this->createPearConfig(); } return ExceptionPear::catchError( - \PEAR_Config::singleton($this->_config_file) + PEAR_Config::singleton($this->_config_file) ); } @@ -273,7 +280,7 @@ public function getPearConfig() */ public function channelExists($channel): bool { - /** @var \PEAR_ChannelFile[] $registered */ + /** @var PEAR_ChannelFile[] $registered */ $registered = $this->getPearConfig()->getRegistry()->getChannels(); foreach ($registered as $c) { if ($channel == $c->getName()) { @@ -306,10 +313,10 @@ public function provideChannel($channel, $options = [], $reason = ''): void * @throws Exception * @throws ExceptionPear */ - private function getInstallationHandler(): \PEAR_Command_Install + private function getInstallationHandler(): PEAR_Command_Install { - $installer = new \PEAR_Command_Install( - new \PEAR_Frontend_CLI(), + $installer = new PEAR_Command_Install( + new PEAR_Frontend_CLI(), $this->getPearConfig() ); return $installer; @@ -420,7 +427,7 @@ public function addChannel($channel, $options = [], $reason = ''): void ); } if (!empty($this->_channeldirectory)) { - $remote = new \Horde_Pear_Remote($channel); + $remote = new Horde_Pear_Remote($channel); file_put_contents($static, $remote->getChannel()); $this->_output->warn( sprintf( @@ -432,8 +439,8 @@ public function addChannel($channel, $options = [], $reason = ''): void } } - $channel_handler = new \PEAR_Command_Channels( - new \PEAR_Frontend_CLI(), + $channel_handler = new PEAR_Command_Channels( + new PEAR_Frontend_CLI(), $this->getPearConfig() ); diff --git a/src/Pear/Factory.php b/src/Pear/Factory.php index 8274e157..1ab0c834 100644 --- a/src/Pear/Factory.php +++ b/src/Pear/Factory.php @@ -15,6 +15,8 @@ use Horde\Components\Exception; use Horde\Components\Exception\Pear as ExceptionPear; use Horde\Components\Pear\Environment as PearEnvironment; +use PEAR_PackageFile; +use PEAR_PackageFile_v2; /** * Components_Pear_Factory:: generates PEAR specific handlers. @@ -51,9 +53,9 @@ public function __construct( * @param string $environment The path to the PEAR environment. * @param string $config_file The path to the configuration file. * - * @return Environment The PEAR environment + * @return PearEnvironment The PEAR environment */ - public function createEnvironment($environment, $config_file): \Horde\Components\Pear\Environment + public function createEnvironment($environment, $config_file): PearEnvironment { $instance = $this->_dependencies->createInstance(PearEnvironment::class); $instance->setFactory($this); @@ -68,14 +70,14 @@ public function createEnvironment($environment, $config_file): \Horde\Components * Create a package representation for a specific PEAR environment. * * @param string $package_file The path of the package XML file. - * @param Environment $environment The PEAR environment. + * @param PearEnvironment $environment The PEAR environment. * * @return Package The PEAR package. */ public function createPackageForEnvironment( $package_file, - Environment $environment - ): \Horde\Components\Pear\Package { + PearEnvironment $environment + ): Package { $package = $this->_createPackage($environment); $package->setPackageXml($package_file); return $package; @@ -89,7 +91,7 @@ public function createPackageForEnvironment( * * @return Package The PEAR package. */ - public function createPackageForPearConfig($package_file, $config_file): \Horde\Components\Pear\Package + public function createPackageForPearConfig($package_file, $config_file): Package { return $this->createPackageForEnvironment( $package_file, @@ -104,7 +106,7 @@ public function createPackageForPearConfig($package_file, $config_file): \Horde\ * * @return Package The PEAR package. */ - public function createPackageForDefaultLocation($package_file): \Horde\Components\Pear\Package + public function createPackageForDefaultLocation($package_file): Package { return $this->createPackageForEnvironment( $package_file, @@ -116,14 +118,14 @@ public function createPackageForDefaultLocation($package_file): \Horde\Component * Create a package representation for a specific PEAR environment based on a *.tgz archive. * * @param string $package_file The path of the package *.tgz file. - * @param Environment $environment The environment for the package file. + * @param PearEnvironment $environment The environment for the package file. * * @return Package The PEAR package. */ public function createTgzPackageForEnvironment( $package_file, - Environment $environment - ): \Horde\Components\Pear\Package { + PearEnvironment $environment + ): Package { $package = $this->_createPackage($environment); $package->setPackageTgz($package_file); return $package; @@ -132,11 +134,11 @@ public function createTgzPackageForEnvironment( /** * Create a generic package representation for a specific PEAR environment. * - * @param Environment $environment The PEAR environment. + * @param PearEnvironment $environment The PEAR environment. * * @return Package The generic PEAR package. */ - private function _createPackage(Environment $environment): \Horde\Components\Pear\Package + private function _createPackage(PearEnvironment $environment): Package { $package = $this->_dependencies->createInstance(Package::class); $package->setFactory($this); @@ -148,14 +150,14 @@ private function _createPackage(Environment $environment): \Horde\Components\Pea * Return the PEAR Package representation. * * @param string $package_xml_path Path to the package.xml file. - * @param Environment $environment The PEAR environment. + * @param PearEnvironment $environment The PEAR environment. */ public function getPackageFile( $package_xml_path, - Environment $environment - ): \PEAR_PackageFile_v2 { + PearEnvironment $environment + ): PEAR_PackageFile_v2 { $config = $environment->getPearConfig(); - $pkg = new \PEAR_PackageFile($config); + $pkg = new PEAR_PackageFile($config); return ExceptionPear::catchError( $pkg->fromPackageFile($package_xml_path, PEAR_VALIDATE_NORMAL) ); @@ -165,13 +167,13 @@ public function getPackageFile( * Return the PEAR Package representation based on a local *.tgz archive. * * @param string $package_tgz_path Path to the *.tgz file. - * @param Environment $environment The PEAR environment. + * @param PearEnvironment $environment The PEAR environment. */ public function getPackageFileFromTgz( $package_tgz_path, PearEnvironment $environment - ): \PEAR_PackageFile { - $pkg = new \PEAR_PackageFile($environment->getPearConfig()); + ): PEAR_PackageFile { + $pkg = new PEAR_PackageFile($environment->getPearConfig()); return ExceptionPear::catchError( $pkg->fromTgzFile($package_tgz_path, PEAR_VALIDATE_NORMAL) ); diff --git a/src/Pear/Package.php b/src/Pear/Package.php index 611ebc31..fa16d64f 100644 --- a/src/Pear/Package.php +++ b/src/Pear/Package.php @@ -13,6 +13,9 @@ use Horde\Components\Exception; use Horde\Components\Output; +use Horde_Pear_Package_Xml; +use PEAR_PackageFile; +use PEAR_PackageFile_v2; /** * Components_Pear_Package:: provides package handling mechanisms. @@ -60,7 +63,7 @@ class Package /** * The package representation. * - * @param \PEAR_PackageFile_v2 + * @param PEAR_PackageFile_v2 */ private $_package_file; @@ -165,7 +168,7 @@ public function setPackageTgz($package_tgz_path) /** * Return the package.xml handler. * - * @return \Horde_Pear_Package_Xml + * @return Horde_Pear_Package_Xml */ private function _getPackageXml() { @@ -177,7 +180,7 @@ private function _getPackageXml() /** * Return the PEAR Package representation. * - * @return \PEAR_PackageFile + * @return PEAR_PackageFile */ private function _getPackageFile() { diff --git a/src/PhpCsFixer/RemovePhpVersionCommentFixer.php b/src/PhpCsFixer/RemovePhpVersionCommentFixer.php index 0aaaed42..c7b27bff 100644 --- a/src/PhpCsFixer/RemovePhpVersionCommentFixer.php +++ b/src/PhpCsFixer/RemovePhpVersionCommentFixer.php @@ -24,6 +24,7 @@ use PhpCsFixer\FixerDefinition\CodeSample; use PhpCsFixer\Tokenizer\Tokens; use PhpCsFixer\Tokenizer\Token; +use SplFileInfo; /** * Removes "PHP Version X" lines from docblocks. @@ -77,7 +78,7 @@ public function getPriority(): int return 10; } - protected function applyFix(\SplFileInfo $file, Tokens $tokens): void + protected function applyFix(SplFileInfo $file, Tokens $tokens): void { foreach ($tokens as $index => $token) { if (!$token->isGivenKind(T_DOC_COMMENT)) { diff --git a/src/PhpCsFixer/UpdateCopyrightYearFixer.php b/src/PhpCsFixer/UpdateCopyrightYearFixer.php index fe46155d..587e3735 100644 --- a/src/PhpCsFixer/UpdateCopyrightYearFixer.php +++ b/src/PhpCsFixer/UpdateCopyrightYearFixer.php @@ -24,6 +24,7 @@ use PhpCsFixer\FixerDefinition\CodeSample; use PhpCsFixer\Tokenizer\Tokens; use PhpCsFixer\Tokenizer\Token; +use SplFileInfo; /** * Updates copyright year ranges to include current year. @@ -74,7 +75,7 @@ public function getPriority(): int return 5; } - protected function applyFix(\SplFileInfo $file, Tokens $tokens): void + protected function applyFix(SplFileInfo $file, Tokens $tokens): void { $currentYear = date('Y'); diff --git a/src/Qc/Task/Base.php b/src/Qc/Task/Base.php index a8c42e2f..07ef3325 100644 --- a/src/Qc/Task/Base.php +++ b/src/Qc/Task/Base.php @@ -99,7 +99,7 @@ protected function getPath(): ?string * * @return Component The component to be checked. */ - protected function getComponent(): ?\Horde\Components\Component + protected function getComponent(): ?Component { return $this->_component; } @@ -139,7 +139,7 @@ protected function getTasks(): QcTasks * * @return Output The output handler. */ - protected function getOutput(): \Horde\Components\Output + protected function getOutput(): Output { return $this->_output; } diff --git a/src/Qc/Task/Lint.php b/src/Qc/Task/Lint.php index 7ee3a2ee..affd0c26 100644 --- a/src/Qc/Task/Lint.php +++ b/src/Qc/Task/Lint.php @@ -11,6 +11,9 @@ namespace Horde\Components\Qc\Task; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; + /** * Components_Qc_Task_Lint:: runs a syntax check on the component. * @@ -46,8 +49,8 @@ public function getName(): string public function run(array &$options = []): int { $lib = realpath($this->getPath()); - $recursion = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($lib) + $recursion = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($lib) ); $errors = 0; foreach ($recursion as $file) { diff --git a/src/Qc/Task/Loc.php b/src/Qc/Task/Loc.php index e4141689..7344db3e 100644 --- a/src/Qc/Task/Loc.php +++ b/src/Qc/Task/Loc.php @@ -16,6 +16,8 @@ use SebastianBergmann\FinderFacade\FinderFacade; use SebastianBergmann\PHPLOC; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; /** * Measure the size and analyze the structure of a PHP component. @@ -67,8 +69,8 @@ public function run(array &$options = []): int // We should probably factor out the component php file finder and reuse it. $componentDir = realpath($this->getPath()); $vendorDir = $componentDir . DIRECTORY_SEPARATOR . 'vendor'; - $recursion = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($componentDir) + $recursion = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($componentDir) ); $errors = 0; foreach ($recursion as $file) { diff --git a/src/Qc/Task/Md.php b/src/Qc/Task/Md.php index 5aa79853..5cd95102 100644 --- a/src/Qc/Task/Md.php +++ b/src/Qc/Task/Md.php @@ -19,6 +19,8 @@ use PHPMD\AbstractRule as PHPMDAbstractRule; use PHPMD\Report as PHPMDReport; +use const STDOUT; + /** * Components_Qc_Task_Md:: runs a mess detection check on the component. * @@ -72,7 +74,7 @@ public function run(array &$options = []): int $src = realpath($this->getPath() . '/src'); $renderer = new PHPMDRendererTextRenderer(); - $renderer->setWriter(new PHPMDWriterStreamWriter(\STDOUT)); + $renderer->setWriter(new PHPMDWriterStreamWriter(STDOUT)); $ruleSetFactory = new PHPMDRuleSetFactory(); $ruleSetFactory->setMinimumPriority(PHPMDAbstractRule::LOWEST_PRIORITY); diff --git a/src/Qc/Task/Metrics.php b/src/Qc/Task/Metrics.php index bd5d3bdc..79efafa9 100644 --- a/src/Qc/Task/Metrics.php +++ b/src/Qc/Task/Metrics.php @@ -157,7 +157,7 @@ private function runPhpMetricsAnalysis( escapeshellarg($targetDir) ); - $this->system($command); + $this->getShell()->system($command); } /** diff --git a/src/Qc/Task/Phpcsfixer.php b/src/Qc/Task/Phpcsfixer.php index 95290a18..7cabf20a 100644 --- a/src/Qc/Task/Phpcsfixer.php +++ b/src/Qc/Task/Phpcsfixer.php @@ -11,6 +11,8 @@ namespace Horde\Components\Qc\Task; +use Phar; + /** * Horde\Components\Qc\Task\Phpcsfixer runs PHP CS Fixer on the component. * @@ -113,8 +115,8 @@ public function run(array &$options = []): int 'files_skipped' => 0, ]; - // Setup config extraction for PHAR context - $configPath = $this->setupConfigForPhar($componentPath); + // Setup config extraction for PHAR context or resolve config based on options + $configPath = $this->setupConfigForPhar($componentPath, $options); // Execute PHP CS Fixer $exitCode = $this->executePhpCsFixer($binary, $componentPath, $isDryRun, $configPath); @@ -483,20 +485,99 @@ private function outputStatistics(bool $isDryRun): void } } + /** + * Resolve configuration file path based on --prefer-config-from option. + * + * @param string $componentPath Path to component being checked. + * @param array $options CLI options including prefer_config_from. + * + * @return string|null Path to config file (null to use default discovery). + */ + private function resolveConfigPath(string $componentPath, array $options): ?string + { + $preference = $options['prefer_config_from'] ?? null; + + // If explicit path provided, use it + if ($preference !== null && $preference !== 'tool' && $preference !== 'uut') { + $explicitPath = $preference; + if (file_exists($explicitPath)) { + $resolvedPath = realpath($explicitPath); + $this->getOutput()->info("Using explicit config: {$resolvedPath}"); + return $resolvedPath; + } + $this->getOutput()->warn("Config path not found: {$explicitPath} - falling back to default"); + $preference = null; // Fall through to default behavior + } + + // "tool" preference - always use horde-components config + if ($preference === 'tool') { + $componentsConfig = __DIR__ . '/../../../.php-cs-fixer.dist.php'; + if (file_exists($componentsConfig)) { + $resolvedPath = realpath($componentsConfig); + $this->getOutput()->info('Using horde-components config (--prefer-config-from=tool)'); + if ($this->getOutput()->isVerbose()) { + $this->getOutput()->info('Config path: ' . $resolvedPath); + } + return $resolvedPath; + } + $this->getOutput()->warn('horde-components config not found'); + return null; + } + + // "uut" preference - only use component's own config + if ($preference === 'uut') { + $componentConfig = $componentPath . '/.php-cs-fixer.dist.php'; + if (file_exists($componentConfig)) { + if ($this->getOutput()->isVerbose()) { + $this->getOutput()->info('Using component config (--prefer-config-from=uut)'); + } + return null; // Let PHP CS Fixer discover it + } + $this->getOutput()->warn('Component has no .php-cs-fixer.dist.php - no config will be used'); + return null; + } + + // Default behavior: component config first, then horde-components as fallback + $componentConfig = $componentPath . '/.php-cs-fixer.dist.php'; + if (file_exists($componentConfig)) { + // Component has own config - let PHP CS Fixer discover it + if ($this->getOutput()->isVerbose()) { + $this->getOutput()->info('Using component\'s own PHP CS Fixer config'); + } + return null; + } + + // No component config - use horde-components config as fallback + $componentsConfig = __DIR__ . '/../../../.php-cs-fixer.dist.php'; + if (file_exists($componentsConfig)) { + $resolvedPath = realpath($componentsConfig); + $this->getOutput()->info('Using horde-components PHP CS Fixer config (component has no own config)'); + if ($this->getOutput()->isVerbose()) { + $this->getOutput()->info('Config path: ' . $resolvedPath); + } + return $resolvedPath; + } + + // No config found anywhere - let PHP CS Fixer use defaults + $this->getOutput()->warn('No PHP CS Fixer config found - using defaults'); + return null; + } + /** * Setup config for PHAR context by extracting config and custom fixers. * * @param string $componentPath Path to component being checked. + * @param array $options CLI options including prefer_config_from. * - * @return string|null Path to config file (null if not in PHAR). + * @return string|null Path to config file (null to use default discovery). */ - private function setupConfigForPhar(string $componentPath): ?string + private function setupConfigForPhar(string $componentPath, array $options = []): ?string { - $pharPath = \Phar::running(false); + $pharPath = Phar::running(false); - // Not running from PHAR - use default config discovery + // Not running from PHAR - resolve config path based on options if ($pharPath === '') { - return null; + return $this->resolveConfigPath($componentPath, $options); } $this->getOutput()->info('Running from PHAR - extracting custom fixers...'); diff --git a/src/Qc/Task/Phpstan.php b/src/Qc/Task/Phpstan.php index 0eab9394..f9e6132c 100644 --- a/src/Qc/Task/Phpstan.php +++ b/src/Qc/Task/Phpstan.php @@ -11,6 +11,8 @@ namespace Horde\Components\Qc\Task; +use Throwable; + /** * Horde\Components\Qc\Task\Phpstan runs PHPStan static analysis on the component. * @@ -119,7 +121,7 @@ public function run(array &$options = []): int // Run at watermark level (MUST PASS) $this->getOutput()->running('Testing watermark level ' . $watermark . '...'); - $watermarkResult = $this->testLevel($binary, $componentPath, $watermark); + $watermarkResult = $this->testLevel($binary, $componentPath, $watermark, $options); if (!$watermarkResult['passed']) { // CODE REGRESSION - fails at watermark! @@ -148,7 +150,7 @@ public function run(array &$options = []): int while ($testLevel <= 9) { $this->getOutput()->info('Testing level ' . $testLevel . '...'); - $result = $this->testLevel($binary, $componentPath, $testLevel); + $result = $this->testLevel($binary, $componentPath, $testLevel, $options); if ($result['passed']) { $this->getOutput()->ok('✓ Level ' . $testLevel . ' passes'); @@ -179,7 +181,7 @@ public function run(array &$options = []): int $this->getOutput()->info('Commit .horde.yml to persist this improvement'); // Re-run at highest level to get final results - $finalResult = $this->testLevel($binary, $componentPath, $highestPassing); + $finalResult = $this->testLevel($binary, $componentPath, $highestPassing, $options); $this->nativeResults = $finalResult['results']; $this->level = $highestPassing; } elseif ($highestPassing === 9) { @@ -190,7 +192,7 @@ public function run(array &$options = []): int } else { // At watermark, cannot raise yet $nextLevel = $watermark + 1; - $nextResult = $this->testLevel($binary, $componentPath, $nextLevel); + $nextResult = $this->testLevel($binary, $componentPath, $nextLevel, $options); $this->getOutput()->ok( '✓ Code passes watermark level ' . $watermark @@ -216,7 +218,7 @@ public function run(array &$options = []): int // Always return 0 if watermark passes (even if we cannot raise) return 0; - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->getOutput()->warn('PHPStan execution failed: ' . $e->getMessage()); return 1; } @@ -269,12 +271,72 @@ private function detectVersion(string $binary): void /** * Find PHPStan configuration file. * + * Supports --prefer-config-from option: + * - "tool": Use horde-components phpstan.neon + * - "uut": Only use component's config (no fallback) + * - path: Use explicit config path + * - default: Component config first, then horde-components fallback + * * @param string $componentPath Path to component. + * @param array $options CLI options including prefer_config_from. * * @return string|null Path to config file or null if not found. */ - private function findConfiguration(string $componentPath): ?string + private function findConfiguration(string $componentPath, array $options = []): ?string { + $preference = $options['prefer_config_from'] ?? null; + + // If explicit path provided, use it + if ($preference !== null && $preference !== 'tool' && $preference !== 'uut') { + $explicitPath = $preference; + if (file_exists($explicitPath)) { + $resolvedPath = realpath($explicitPath); + $this->getOutput()->info("Using explicit config: {$resolvedPath}"); + return $resolvedPath; + } + $this->getOutput()->warn("Config path not found: {$explicitPath} - falling back to default"); + $preference = null; // Fall through to default behavior + } + + // "tool" preference - use horde-components config + if ($preference === 'tool') { + $componentsConfig = __DIR__ . '/../../../phpstan.neon'; + if (file_exists($componentsConfig)) { + $resolvedPath = realpath($componentsConfig); + $this->getOutput()->info('Using horde-components config (--prefer-config-from=tool)'); + if ($this->getOutput()->isVerbose()) { + $this->getOutput()->info('Config path: ' . $resolvedPath); + } + return $resolvedPath; + } + $this->getOutput()->warn('horde-components phpstan.neon not found'); + return null; + } + + // "uut" preference - only use component's own config + if ($preference === 'uut') { + $componentConfigs = [ + $componentPath . '/phpstan.neon', + $componentPath . '/phpstan.neon.dist', + $componentPath . '/phpstan.dist.neon', + $componentPath . '/.phpstan.neon', + $componentPath . '/.phpstan.neon.dist', + ]; + + foreach ($componentConfigs as $config) { + if (file_exists($config)) { + if ($this->getOutput()->isVerbose()) { + $this->getOutput()->info('Using component config (--prefer-config-from=uut)'); + } + return $config; + } + } + + $this->getOutput()->warn('Component has no phpstan.neon - no config will be used'); + return null; + } + + // Default behavior: component config first $possibleConfigs = [ $componentPath . '/phpstan.neon', $componentPath . '/phpstan.neon.dist', @@ -285,10 +347,25 @@ private function findConfiguration(string $componentPath): ?string foreach ($possibleConfigs as $config) { if (file_exists($config)) { + if ($this->getOutput()->isVerbose()) { + $this->getOutput()->info('Using component\'s own PHPStan config'); + } return $config; } } + // No component config - use horde-components config as fallback + $componentsConfig = __DIR__ . '/../../../phpstan.neon'; + if (file_exists($componentsConfig)) { + $resolvedPath = realpath($componentsConfig); + $this->getOutput()->info('Using horde-components PHPStan config (component has no own config)'); + if ($this->getOutput()->isVerbose()) { + $this->getOutput()->info('Config path: ' . $resolvedPath); + } + return $resolvedPath; + } + + // No config found return null; } @@ -307,7 +384,7 @@ private function getWatermarkFromHordeYml(): int if (isset($hordeYml['quality']['phpstan']['level'])) { return (int) $hordeYml['quality']['phpstan']['level']; } - } catch (\Throwable $e) { + } catch (Throwable $e) { // Fall through to default } @@ -377,7 +454,7 @@ private function updateWatermarkInHordeYml(int $newLevel, int $oldLevel): void $this->getOutput()->info('Updated .horde.yml watermark: ' . $oldLevel . ' → ' . $newLevel); - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->getOutput()->warn('Failed to update .horde.yml: ' . $e->getMessage()); } } @@ -388,10 +465,11 @@ private function updateWatermarkInHordeYml(int $newLevel, int $oldLevel): void * @param string $binary Path to PHPStan binary. * @param string $componentPath Path to component. * @param int $level Level to test (0-9). + * @param array $options CLI options. * * @return array ['passed' => bool, 'errors' => int, 'exit_code' => int, 'results' => array|null] */ - private function testLevel(string $binary, string $componentPath, int $level): array + private function testLevel(string $binary, string $componentPath, int $level, array $options = []): array { $cmd = [ escapeshellarg($binary), @@ -406,7 +484,7 @@ private function testLevel(string $binary, string $componentPath, int $level): a // Do NOT use config file when testing levels // Config files may contain level settings that override --level argument // Always explicitly specify paths instead - $configPath = $this->findConfiguration($componentPath); + $configPath = $this->findConfiguration($componentPath, $options); if ($configPath !== null) { // Parse paths from config, but don't use config file $paths = $this->getPathsFromConfig($configPath); diff --git a/src/Qc/Task/Unit.php b/src/Qc/Task/Unit.php index 5bc67688..88ecf21a 100644 --- a/src/Qc/Task/Unit.php +++ b/src/Qc/Task/Unit.php @@ -17,6 +17,9 @@ use PHPUnit\Event\Test\Failed; use PHPUnit\Event\Test\Finished; use PHPUnit\Event\Test\Skipped; +use ReflectionClass; +use ReflectionException; +use Throwable; /** * Components_Qc_Task_Unit:: runs the test suite of the component. @@ -142,7 +145,7 @@ private function getPhpUnitVersion(): string if (class_exists('PHPUnit\Runner\Version')) { return \PHPUnit\Runner\Version::id(); } - } catch (\Throwable $e) { + } catch (Throwable $e) { // Ignore } return ''; @@ -159,7 +162,7 @@ private function detectPhpUnitSource(): void $versionStr = $version ? ' version ' . $version : ''; try { - $reflection = new \ReflectionClass('PHPUnit\TextUI\Application'); + $reflection = new ReflectionClass('PHPUnit\TextUI\Application'); $filename = $reflection->getFileName(); if ($filename === false) { @@ -193,7 +196,7 @@ private function detectPhpUnitSource(): void } else { $this->getOutput()->info('Using PHPUnit' . $versionStr . ' from: ' . dirname($filename) . ' (system)'); } - } catch (\ReflectionException $e) { + } catch (ReflectionException $e) { $this->getOutput()->info('Using PHPUnit' . $versionStr . ' (source detection failed)'); } } diff --git a/src/Qc/ToolFinder.php b/src/Qc/ToolFinder.php index 32758175..e9dbe212 100644 --- a/src/Qc/ToolFinder.php +++ b/src/Qc/ToolFinder.php @@ -11,6 +11,10 @@ namespace Horde\Components\Qc; +use Phar; +use PharException; +use Throwable; + /** * Tool binary finder for quality check tasks. * @@ -205,16 +209,16 @@ public function loadPhar(string $path): bool try { // First, validate it's a PHAR using PHP's built-in validation // This checks the GBMB signature at the end of the file - \Phar::loadPhar($path); + Phar::loadPhar($path); // Then require it to execute the stub and register autoloaders require_once $path; return true; - } catch (\PharException $e) { + } catch (PharException $e) { // Not a valid PHAR file return false; - } catch (\Throwable $e) { + } catch (Throwable $e) { // Other errors (permissions, require_once already called, etc.) // Note: require_once is safe to call multiple times return false; @@ -238,11 +242,11 @@ public function isPhar(string $path): bool } try { - \Phar::loadPhar($path); + Phar::loadPhar($path); return true; - } catch (\PharException $e) { + } catch (PharException $e) { return false; - } catch (\Throwable $e) { + } catch (Throwable $e) { return false; } } @@ -279,7 +283,7 @@ public function loadTool(string $toolPath): bool require_once $autoloader; return true; } - } catch (\Throwable $e) { + } catch (Throwable $e) { return false; } diff --git a/src/Release/HordeRelease.php b/src/Release/HordeRelease.php index 712660d1..91f9b9d9 100644 --- a/src/Release/HordeRelease.php +++ b/src/Release/HordeRelease.php @@ -2,51 +2,45 @@ namespace Horde\Components\Release; -use Horde\Components\Helper\Git as GitHelper; use Horde\Components\Helper\Composer as ComposerHelper; -use Horde\Components\Helper\ConventionalCommitHelper; -use Horde\Components\Helper\Version; -use Horde\Components\Helper\GitHubChecker; +use Horde\Components\Helper\Git as GitHelper; use Horde\Components\Helper\GitHubReleaseCreator; -use Horde\Components\Wrapper\HordeYml; -use Horde\Components\Wrapper\ChangelogYml; +use Horde\Components\Helper\Shell as ShellHelper; use Horde\Components\Component\ComponentDirectory; use Horde\Components\Exception; -use Horde\Components\Wrapper\ComposerJson; use Horde\Components\Output; use Horde\Components\Component; use Horde\Components\Qc\Tasks as QcTasks; -use RecursiveDirectoryIterator; -use RecursiveIteratorIterator; -use SplFileInfo; -use Horde\Components\Wrapper\ApplicationPhp; -use Horde\Components\ChangelogEntry; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Horde\Components\Task\Version\AnalyzeConventionalCommitsTask; +use Horde\Components\Task\Version\CalculateNextVersionTask; +use Horde\Components\Task\Release\UpdateHordeYmlVersionTask; +use Horde\Components\Task\Release\AddChangelogEntryTask; +use Horde\Components\Task\Release\UpdateComposerJsonTask; +use Horde\Components\Task\Release\UpdateApplicationSentinelTask; +use Horde\Components\Task\Release\CleanupLegacyFilesTask; +use Horde\Components\Task\Composer\ComposerValidateTask; +use Horde\Components\Task\Git\CommitTask; +use Horde\Components\Task\Git\TagTask; +use Horde\Components\Task\Git\PushTask; +use Horde\Components\Task\GitHub\CreateGitHubReleaseTask; +use Horde\Components\Task\Build\BuildPharTask; +use Horde\Components\Task\GitHub\UploadGitHubAssetTask; +use ReflectionClass; /** - * Horde 6 Release pipeline + * Horde 6 Release pipeline using modernized task architecture * - * This is an attempt at redesigning from the old H4/H5 style releases + * Copyright 2020-2026 Horde LLC (http://www.horde.org/) * - * Supposed to be run on the release branch after including code (i.e. after PR) - * but before writing the metadata and tagging. + * See the enclosed file LICENSE for license information (LGPL). If you + * did not receive this file, see http://www.horde.org/licenses/lgpl21. * - * Post release tagging metadata will NOT be updated for "next version". - * - Check if we are on release branch - * - Read ConventionalCommits & expected next version - * - Bail out on inappropriate, i.e. no conventional commits included - * - write .horde.yml versions - * - move changelog.yml to doc basedir if it is nested into subdirs - * - write changelog.yml updates - * - write composer.json from changelog and horde.yml - * - Remove package.xml and CHANGES file if present. - * - Composer validate - * - write application.php Sentinel - * - Run any document pulls from Wiki or other sources - * - commit for release - * - tag & push - * - Post Tasks, trigger packagist and horde infra apis - * - Post release commit if needed. - * - + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ class HordeRelease { @@ -55,348 +49,233 @@ public function __construct( private GitHelper $gitHelper, private ComponentDirectory $directory, private Output $output, - private GitHubChecker $githubChecker, private GitHubReleaseCreator $githubReleaseCreator, private QcTasks $qcTasks, + private ShellHelper $shellHelper, + private bool $pretend = false ) {} + /** - * Run the release flow. Most steps should be idempotent. + * Run the release flow using modernized tasks. * * @param Component $component The component to release * @param array $options Options array */ public function run(Component $component, array $options = []) { - $currentBranch = $this->gitHelper->getCurrentBranch($this->directory); - // Check if we are on release branch - if ($currentBranch !== 'FRAMEWORK_6_0') { - throw new Exception('Not on release branch. Please switch to the release branch before running this script.'); + $this->output->warn('H6 Release Pipeline'); + + if ($this->pretend) { + $this->output->warn('[PRETEND MODE] No changes will be made'); } - // Early QC: Check and fix .gitignore before starting release process - $this->output->ok('Running pre-release QC checks...'); - $gitignoreTask = $this->qcTasks->getTask('gitignore', $component); + // Create context for task execution + $context = new Context($component, $options); - // Enable auto-fix for gitignore during release - $qcOptions = array_merge($options, ['fix_qc_issues' => true]); + try { + // Pre-flight checks + $this->validateReleaseBranch(); + $this->runGitignoreQc($component, $options); - $errors = $gitignoreTask->validate($qcOptions); - if (empty($errors)) { - $gitignoreErrors = $gitignoreTask->run($qcOptions); - if ($gitignoreErrors > 0) { - $this->output->warn('Gitignore check found issues but they were auto-fixed'); + // Determine version (auto-calculate or manual) + if (!empty($options['next_version'])) { + $this->output->ok('Using manual version: ' . $options['next_version']); + $context->setOption('next_version', $options['next_version']); + } else { + $this->output->info('Auto-calculating version from conventional commits'); + $this->runTask(new AnalyzeConventionalCommitsTask($this->output, $this->gitHelper, $this->pretend), $context); } - } else { - $this->output->warn('Gitignore task validation failed: ' . implode(', ', $errors)); - } - // Determine the target version - $history = null; - $skipPostReleaseEdits = false; - - if (!empty($options['next_version'])) { - // User supplied a manual version - normalize it - $this->output->ok('Explicit target version provided: ' . $options['next_version']); - $nextVersion = Version::fromComposerString($options['next_version']); - $nextTag = $nextVersion->toHordeTag(); - $this->output->info('Normalized to tag: ' . $nextTag . ' (SemVer: ' . $nextVersion->toFullSemverV2() . ')'); - - // Check if this version/tag already exists locally or remotely - if ($this->gitHelper->localTagExists((string) $this->directory, $nextTag)) { - $this->output->info("Tag '{$nextTag}' exists locally. Performing post-release edits only."); - $skipPostReleaseEdits = true; - } elseif ($this->gitHelper->hasRemotes((string) $this->directory) - && $this->gitHelper->remoteTagExists((string) $this->directory, $nextTag)) { - throw new Exception( - sprintf( - 'Tag "%s" already exists on remote. Cannot release version %s again.', - $nextTag, - $nextVersion->toFullSemverV2() - ) - ); - } - } else { - // Auto-calculate version from conventional commits - $this->output->info('Auto-calculating version from conventional commits'); - $history = new ConventionalCommitHelper($this->gitHelper); + // Calculate/normalize version with reality check + $this->runTask(new CalculateNextVersionTask($this->output, $this->gitHelper, $this->pretend), $context); - // Bail out on inappropriate, i.e. no conventional commits included - if (count($history->commitReader->getLog()) == 0) { - throw new Exception('No conventional commits found since last tag. Please ensure you have made commits in the correct format.'); - } + // Get calculated version for display + $nextVersion = $context->getFact('version.next'); + $this->output->ok('Releasing version: ' . $nextVersion->toFullSemverV2()); - $nextVersion = $history->nextVersion; - $nextTag = $nextVersion->toHordeTag(); - - // Precheck: If remotes are configured, check if the next version tag already exists - if ($this->gitHelper->hasRemotes((string) $this->directory)) { - if ($this->gitHelper->remoteTagExists((string) $this->directory, $nextTag)) { - throw new Exception( - sprintf( - "Tag \"%s\" already exists on remote. Cannot release version %s again.\n" - . "Suggestion: Use --next-version to specify a different version (e.g., --next-version %s)", - $nextTag, - $nextVersion->toFullSemverV2(), - $nextVersion->nextVersionObject()->toFullSemverV2() - ) - ); - } - } - } + // Update metadata files + $this->runTask(new UpdateHordeYmlVersionTask($this->output, $this->pretend), $context); - // If tag exists locally and we're using manual version, only do post-release edits - if ($skipPostReleaseEdits) { - $this->output->warn('Skipping main release process - tag already exists locally'); - // TODO: Perform post-release edits here - return; - } + // Prepare release notes for changelog + $releaseNotes = $this->formatReleaseNotes($context, $options); + $context->setOption('release_notes', $releaseNotes); - $this->output->ok('Releasing version: ' . $nextVersion->toFullSemverV2() . ' (tag: ' . $nextTag . ')'); + $this->runTask(new AddChangelogEntryTask($this->output, $this->gitHelper, $this->pretend), $context); + $this->runTask(new UpdateComposerJsonTask($this->output, $this->composerHelper, $this->gitHelper, $this->pretend), $context); + $this->runTask(new UpdateApplicationSentinelTask($this->output, $this->pretend), $context); - // Read conventional commits for changelog notes (if not using manual version) - $logNotes = ''; - $topSeverity = 'subpatch'; // Default for manual versions - if ($history !== null) { - $topSeverity = $history->commitReader->getTopSeverity(); - foreach ($history->commitReader->getLog() as $commit) { - // TODO: Nice Format - $logNotes .= $commit->subject . "\n"; - } - } else { - $logNotes = "Release version " . $nextVersion->toFullSemverV2(); - } + // Clean up legacy files + $this->runTask(new CleanupLegacyFilesTask($this->output, $this->gitHelper, $this->pretend), $context); - // write .horde.yml versions and stabilities - $hordeYml = new HordeYml($this->directory); - $hordeYml->setReleaseVersionAndStability($nextVersion); - // TODO: Logic on when to set API version and stability - // $hordeYml->setApiVersionAndStability($nextVersion); - $hordeYml->save(); - // While apps used to have changelog.yml in doc/, libs had it in doc/long/lib/name - simplify this - // write changelog.yml - $splFileInfo = self::findFile((string) $this->directory . '/doc', 'changelog.yml'); - if ($splFileInfo && ($splFileInfo->getPathname() !== $this->directory . '/doc/changelog.yml')) { - $this->gitHelper->moveFile($splFileInfo->getPathname(), $this->directory . '/doc/changelog.yml'); - } + // Validate composer.json + $this->runTask(new ComposerValidateTask($this->output, $this->shellHelper, $this->pretend), $context); - $changelog = new ChangelogYml($this->directory . '/doc'); - $entry = new ChangelogEntry( - releaseVersion: $hordeYml->getReleaseVersion(), - apiVersion: $hordeYml->getApiVersion(), - // Date defaults to today - license: $hordeYml->getLicense(), - notes: $logNotes - ); - $changelog->addChangelogEntry($entry); - $changelog->save(); - // write composer.json from changelog and horde.yml - $this->composerHelper->generateComposerJson($hordeYml, ['composer_version' => 'dev-' . $currentBranch]); - - // Remove CHANGES file if present - $splFileInfo = self::findFile((string) $this->directory . '/doc', 'CHANGES'); - if ($splFileInfo && ($splFileInfo->getPathname() !== (string) $this->directory . '/doc')) { - $this->gitHelper->deleteFile($splFileInfo->getPathname()); - } - // Remove package.xml file if present - $packagePath = (string) $this->directory . '/package.xml'; - if (file_exists($packagePath)) { - $this->gitHelper->deleteFile($packagePath); - } - // TODO: Composer validate - // TODO: write application.php Sentinel - if (in_array($hordeYml['type'], ['application', 'horde-application'])) { - $applicationPhp = new ApplicationPhp($this->directory); - $applicationPhp->setVersion($hordeYml->getReleaseVersion()->toFullSemverV2()); - $applicationPhp->save(); - } - // TODO: Run any document pulls from Wiki or other sources - // commit for release using Conventional Commit format + // Git operations: commit, tag, push + $this->prepareCommitMessage($context); + $this->runTask(new CommitTask($this->output, $this->gitHelper, $this->pretend), $context); + $this->runTask(new TagTask($this->output, $this->gitHelper, $this->pretend), $context); + $this->runTask(new PushTask($this->output, $this->gitHelper, $this->pretend), $context); - // Add lib/Application.php only if it exists (applications only, not libraries) - $applicationPath = (string) $this->directory . '/lib/Application.php'; - if (file_exists($applicationPath)) { - $this->gitHelper->add($applicationPath); - } + // GitHub release + $this->runTask(new CreateGitHubReleaseTask($this->output, $this->githubReleaseCreator, $this->gitHelper, $this->pretend), $context); - $this->gitHelper->add((string) $this->directory . '/doc/changelog.yml'); - $this->gitHelper->add((string) $this->directory . '/.horde.yml'); - $this->gitHelper->add((string) $this->directory . '/composer.json'); + // Build and upload PHAR (if applicable) + $buildTask = new BuildPharTask($this->output, $this->shellHelper, $this->pretend); + if (!$buildTask->shouldSkip($context)) { + $this->runTask($buildTask, $context); - // Add .gitignore if it exists - $gitignorePath = (string) $this->directory . '/.gitignore'; - if (file_exists($gitignorePath)) { - $this->gitHelper->add($gitignorePath); + $uploadTask = new UploadGitHubAssetTask($this->output, $this->githubReleaseCreator, $this->pretend); + if (!$uploadTask->shouldSkip($context)) { + $this->runTask($uploadTask, $context); + } + } + + // Success! + if ($this->pretend) { + $this->output->ok('[PRETEND MODE] Release pipeline completed successfully (no changes made)'); + } else { + $this->output->ok('Release ' . $nextVersion->toFullSemverV2() . ' completed successfully!'); + } + } catch (\Exception $e) { + $this->output->fail('Release failed: ' . $e->getMessage()); + throw $e; } + } - $releaseVersion = $hordeYml->getReleaseVersion()->toFullSemverV2(); - $apiVersion = $hordeYml->getApiVersion()->toFullSemverV2(); + /** + * Validate that we're on the correct release branch. + * + * @throws Exception if not on release branch + */ + private function validateReleaseBranch(): void + { + $currentBranch = $this->gitHelper->getCurrentBranch((string) $this->directory); - // Conventional Commit format: chore(release): bump version to X.Y.Z - $releaseMessage = sprintf( - "chore(release): bump version to %s\n\nRelease version %s (API Version: %s)\n\n%s", - $releaseVersion, - $releaseVersion, - $apiVersion, - $logNotes - ); + if ($currentBranch !== 'FRAMEWORK_6_0') { + throw new Exception( + 'Not on release branch. Please switch to FRAMEWORK_6_0 branch before running release.' + ); + } - $this->gitHelper->commit( - (string) $this->directory, - $releaseMessage - ); - // TODO: tag & push - $this->gitHelper->tag( - (string) $this->directory, - $hordeYml->getReleaseVersion()->toHordeTag(), - $releaseMessage - ); - $this->gitHelper->push( - (string) $this->directory, - 'origin', - $currentBranch, - $hordeYml->getReleaseVersion()->toHordeTag() - ); + $this->output->info('On release branch: ' . $currentBranch); + } - // Create GitHub release if this is a GitHub repository - $releaseTag = $hordeYml->getReleaseVersion()->toHordeTag(); - $releaseName = $hordeYml->getName() . ' ' . $hordeYml->getReleaseVersion()->toFullSemverV2(); - $isPrerelease = in_array($hordeYml->getReleaseVersion()->getStability(), ['alpha', 'beta', 'RC']); + /** + * Run gitignore QC task with auto-fix enabled. + * + * @param Component $component The component + * @param array $options Options array + */ + private function runGitignoreQc(Component $component, array $options): void + { + $this->output->info('Running pre-release QC checks...'); - // Format release notes with severity indicator - $formattedNotes = GitHubReleaseCreator::formatReleaseNotes($logNotes, $topSeverity); + $gitignoreTask = $this->qcTasks->getTask('gitignore', $component); - $release = $this->githubReleaseCreator->createRelease( - localDir: (string) $this->directory, - tagName: $releaseTag, - releaseName: $releaseName, - releaseBody: $formattedNotes, - prerelease: $isPrerelease - ); + // Enable auto-fix for gitignore during release + $qcOptions = array_merge($options, ['fix_qc_issues' => true]); - // If release was created and box.json.dist exists, build and upload PHAR - if ($release !== null) { - $this->buildAndUploadPhar($release, $hordeYml); + $errors = $gitignoreTask->validate($qcOptions); + if (empty($errors)) { + $gitignoreErrors = $gitignoreTask->run($qcOptions); + if ($gitignoreErrors > 0) { + $this->output->warn('Gitignore check found issues but they were auto-fixed'); + } else { + $this->output->ok('Gitignore check passed'); + } + } else { + $this->output->warn('Gitignore task validation failed: ' . implode(', ', $errors)); } - - // TODO: Post Tasks, trigger packagist and horde infra apis - // Post release commit if needed. } /** - * Build and upload PHAR file if box.json.dist exists and box is available + * Format release notes from commits or manual version. * - * @param \Horde\GithubApiClient\GithubRelease $release The GitHub release to upload to - * @param HordeYml $hordeYml The component metadata + * @param Context $context Task context + * @param array $options CLI options + * @return string Formatted release notes */ - private function buildAndUploadPhar(\Horde\GithubApiClient\GithubRelease $release, HordeYml $hordeYml): void + private function formatReleaseNotes(Context $context, array $options): string { - $boxConfig = $this->directory . '/box.json.dist'; + $nextVersion = $context->getFact('version.next'); - // Check if box.json.dist exists - if (!file_exists($boxConfig)) { - $this->output->info('No box.json.dist found, skipping PHAR build'); - return; + // If we have formatted notes from commits, use them + if ($context->hasFact('notes.formatted')) { + return $context->getFact('notes.formatted'); } - // Check if box utility is available - $boxPath = trim((string) shell_exec('which box 2>/dev/null')); - if (empty($boxPath)) { - $this->output->warn('Box utility not found in PATH, skipping PHAR build'); - $this->output->help('Install box from https://github.com/box-project/box'); - return; - } - - $this->output->info('Building PHAR with Box...'); + // Manual version - use simple message + return "Release version " . $nextVersion->toFullSemverV2(); + } - // Ensure build directory exists - $buildDir = $this->directory . '/build'; - if (!is_dir($buildDir)) { - $this->output->info('Creating build/ directory'); - mkdir($buildDir, 0o755, true); - } + /** + * Prepare commit message for release commit. + * + * @param Context $context Task context + */ + private function prepareCommitMessage(Context $context): void + { + $nextVersion = $context->getFact('version.next'); + $releaseNotes = $context->getOption('release_notes'); + $tagName = $context->getFact('version.tag_name'); - // Build the PHAR - $buildCommand = sprintf( - 'cd %s && %s compile --config=%s 2>&1', - escapeshellarg((string) $this->directory), - escapeshellarg($boxPath), - escapeshellarg('box.json.dist') + // Conventional Commit format: chore(release): bump version to X.Y.Z + $commitMessage = sprintf( + "chore(release): bump version to %s\n\nRelease version %s\n\n%s", + $nextVersion->toFullSemverV2(), + $nextVersion->toFullSemverV2(), + $releaseNotes ); - $buildOutput = []; - $returnCode = 0; - exec($buildCommand, $buildOutput, $returnCode); - - if ($returnCode !== 0) { - $this->output->warn('Failed to build PHAR:'); - $this->output->plain(implode("\n", $buildOutput)); - return; + // Files modified by release tasks + $filesToCommit = [ + '.gitignore', + '.horde.yml', + 'doc/changelog.yml', + 'composer.json', + ]; + + // Add application sentinel files if they exist + $componentPath = $context->getComponentPath(); + if (file_exists($componentPath . '/lib/Application.php')) { + $filesToCommit[] = 'lib/Application.php'; } - $this->output->ok('PHAR built successfully'); - - // Read the output path from box.json.dist to find the correct PHAR - $boxConfigContent = file_get_contents($boxConfig); - $boxConfigData = json_decode($boxConfigContent, true); - - if (!isset($boxConfigData['output'])) { - $this->output->warn('Could not find output path in box.json.dist'); - return; - } + $context->setOption('commit_message', $commitMessage); + $context->setOption('files', $filesToCommit); + $context->setOption('tag', $tagName); + $context->setOption('message', $commitMessage); + } - // The output path is relative to component directory - $pharPath = $this->directory . '/' . $boxConfigData['output']; + /** + * Run a task and handle its result. + * + * @param object $task The task to run + * @param Context $context Task context + * @throws Exception if task fails + */ + private function runTask(object $task, Context $context): void + { + $taskName = (new ReflectionClass($task))->getShortName(); - if (!file_exists($pharPath)) { - $this->output->warn("PHAR file not found at expected location: {$pharPath}"); + // Check if task should be skipped + if (method_exists($task, 'shouldSkip') && $task->shouldSkip($context)) { + $this->output->info("Skipping {$taskName} (preconditions not met)"); return; } - $this->output->info("Found PHAR: " . basename($pharPath)); + $this->output->plain(''); + $this->output->info("Running {$taskName}..."); - // Create versioned asset name - // Extract base name without version (e.g., "horde-components" from "horde-components.phar") - $baseNameWithExt = basename($pharPath); - $baseName = preg_replace('/\.phar$/', '', $baseNameWithExt); + $result = $task->run($context); - // Remove any existing version from the base name if present - // Handles names like "horde-components-1.0.0-alpha23" -> "horde-components" - $baseName = preg_replace('/-\d+\.\d+\.\d+.*$/', '', $baseName); - - $version = $hordeYml->getReleaseVersion()->toFullSemverV2(); - $assetName = "{$baseName}-{$version}.phar"; - - // Upload the PHAR as a release asset - $uploadSuccess = $this->githubReleaseCreator->uploadPharAsset( - $release, - $pharPath, - $assetName - ); - - // Rename the local PHAR file to match the versioned name - if ($uploadSuccess) { - $versionedPath = dirname($pharPath) . '/' . $assetName; - if (rename($pharPath, $versionedPath)) { - $this->output->ok("Renamed local PHAR to: {$assetName}"); - } else { - $this->output->warn("Failed to rename local PHAR file"); - } - } - } - - - public static function findFile( - string $sourceRootDir, - string $sourceFilename, - ): ?SplFileInfo { - $recDirIterator = new RecursiveDirectoryIterator($sourceRootDir); - $recIteratorIterator = new RecursiveIteratorIterator($recDirIterator); - foreach ($recIteratorIterator as $splFileInfo) { - if ($splFileInfo->getFilename() === $sourceFilename) { - return $splFileInfo; - } + if ($result->isSuccess()) { + $this->output->ok(' ✓ ' . $result->message); + } elseif ($result->isSkipped()) { + $this->output->info(' ⊘ ' . $result->message); + } else { + $this->output->fail(' ✗ ' . $result->message); + throw new Exception("Task {$taskName} failed: " . $result->message); } - return null; } - } diff --git a/src/Release/Notes.php b/src/Release/Notes.php index 3d940362..d0ddbe20 100644 --- a/src/Release/Notes.php +++ b/src/Release/Notes.php @@ -19,6 +19,9 @@ use Horde\Components\Output; use Horde\Components\Exception; use Horde\Components\Util\YamlLoader; +use Horde; +use Horde_String; +use NumberFormatter; /** * This class deals with the information associated to a release. @@ -75,7 +78,7 @@ protected function _setReleaseNotes(): void $version = HelperVersion::parsePearVersion( $this->_component->getVersion() ); - $description = \Horde_String::lower($version->description); + $description = Horde_String::lower($version->description); if (!str_contains($description, 'release')) { $description .= ' release'; } @@ -143,7 +146,7 @@ protected function _setReleaseNotes(): void %s The major changes compared to the %s version %s are:%s', $version->subversion - ? \NumberFormatter::create('en_US', \NumberFormatter::ORDINAL) + ? NumberFormatter::create('en_US', NumberFormatter::ORDINAL) ->format($version->subversion) . ' ' : '', $description, @@ -175,7 +178,7 @@ protected function _setReleaseNotes(): void public function getBranch(): string { if (!empty($this->_notes['branch']) - && $this->_notes['name'] != \Horde::class) { + && $this->_notes['name'] != Horde::class) { return strtr($this->_notes['branch'], ['Horde ' => 'H']); } return ''; diff --git a/src/Release/Task/Announce.php b/src/Release/Task/Announce.php index 2d0b2037..01986a10 100644 --- a/src/Release/Task/Announce.php +++ b/src/Release/Task/Announce.php @@ -13,6 +13,8 @@ namespace Horde\Components\Release\Task; use Horde\Components\Exception; +use Horde_Mail_Transport_Sendmail; +use Horde_Release_MailingList; /** * Components_Release_Task_Announce:: announces new releases to the mailing @@ -47,7 +49,7 @@ public function preValidate($options): array if (empty($options['from'])) { $errors[] = 'The "from" option has no value. Who is sending the announcements?'; } - if (!class_exists(\Horde_Release_MailingList::class)) { + if (!class_exists(Horde_Release_MailingList::class)) { $errors[] = 'The \Horde_Release package is missing (specifically the class \Horde_Release_MailingList)!'; } return $errors; @@ -67,7 +69,7 @@ public function run(&$options): void return; } - $mailer = new \Horde_Release_MailingList( + $mailer = new Horde_Release_MailingList( $this->getComponent()->getName(), $this->getNotes()->getName(), $this->getNotes()->getBranch(), @@ -91,7 +93,7 @@ public function run(&$options): void if (!$this->getTasks()->pretend()) { try { //@todo: Make configurable again - $class = \Horde_Mail_Transport_Sendmail::class; + $class = Horde_Mail_Transport_Sendmail::class; $mailer->getMail()->send(new $class([])); } catch (Exception $e) { $this->getOutput()->warn((string) $e); diff --git a/src/Release/Task/Bugs.php b/src/Release/Task/Bugs.php index 267c504e..6e323918 100644 --- a/src/Release/Task/Bugs.php +++ b/src/Release/Task/Bugs.php @@ -23,6 +23,8 @@ use Horde\Http\RequestFactory; use Horde\Http\StreamFactory; use Horde\Http\ResponseFactory; +use Horde_Exception; +use Horde_Release_Whups; /** * Components_Release_Task_Bugs adds the new release to the issue tracker. @@ -50,19 +52,23 @@ class Bugs extends Base public function preValidate($options): array { $errors = []; - if (empty($options['horde_user'])) { - $errors[] = 'The "horde_user" option has no value. Who is updating bugs.horde.org?'; + // Try new names first, then fall back to old names for backwards compatibility + $whupsUser = $options['whups.user'] ?? $options['horde_user'] ?? null; + $whupsPass = $options['whups.pass'] ?? $options['horde_pass'] ?? null; + + if (empty($whupsUser)) { + $errors[] = 'The "whups.user" option has no value. Who is updating bugs.horde.org?'; } - if (empty($options['horde_pass'])) { - $errors[] = 'The "horde_pass" option has no value. What is your password for updating bugs.horde.org?'; + if (empty($whupsPass)) { + $errors[] = 'The "whups.pass" option has no value. What is your password for updating bugs.horde.org?'; } - if (!class_exists(\Horde_Release_Whups::class)) { + if (!class_exists(Horde_Release_Whups::class)) { $errors[] = 'The \Horde_Release package is missing (specifically the class \Horde_Release_Whups)!'; } try { $this->_qid = $this->_getBugs($options) ->getQueueId($this->getComponent()->getName()); - } catch (\Horde_Exception $e) { + } catch (Horde_Exception $e) { $errors[] = sprintf( 'Failed accessing bugs.horde.org: %s', $e->getMessage() @@ -79,17 +85,21 @@ public function preValidate($options): array * * @param array $options Additional options. */ - public function _getBugs($options): \Horde_Release_Whups + public function _getBugs($options): Horde_Release_Whups { - if (!isset($options['horde_user']) || !isset($options['horde_user'])) { + // Try new names first, then fall back to old names for backwards compatibility + $whupsUser = $options['whups.user'] ?? $options['horde_user'] ?? ''; + $whupsPass = $options['whups.pass'] ?? $options['horde_pass'] ?? ''; + + if (!isset($whupsUser) || !isset($whupsPass)) { throw new Exception('Missing credentials!'); } $httpClient = new Curl( new ResponseFactory(), new StreamFactory(), new Options([ - 'request.username' => $options['horde_user'], - 'request.password' => $options['horde_pass'], + 'request.username' => $whupsUser, + 'request.password' => $whupsPass, 'request.timeout' => 10, ]) ); @@ -98,7 +108,7 @@ public function _getBugs($options): \Horde_Release_Whups new RequestFactory(), new StreamFactory() ); - return new \Horde_Release_Whups( + return new Horde_Release_Whups( [ 'client' => $client, 'url' => 'https://dev.horde.org/horde/rpc.php', @@ -139,7 +149,7 @@ public function run(&$options): void $ticket_version, $ticket_description ); - } catch (\Horde_Exception $e) { + } catch (Horde_Exception $e) { $this->getOutput()->warn('Cannot update version on bugs.horde.org.'); $this->getOutput()->warn($e->getMessage()); } diff --git a/src/Release/Task/GitPush.php b/src/Release/Task/GitPush.php index 2efa2bad..5bc42ce5 100644 --- a/src/Release/Task/GitPush.php +++ b/src/Release/Task/GitPush.php @@ -86,7 +86,7 @@ public function skip($options): bool * @return SystemCallResult The result object * Might make sense to factor out into a git helper for reuse? */ - protected function _push(string $remote, string $branch = ''): \Horde\Components\Component\Task\SystemCallResult + protected function _push(string $remote, string $branch = ''): SystemCallResult { return $this->getShell()->exec( sprintf('git push --set-upstream %s %s', $remote, $branch), @@ -103,7 +103,7 @@ protected function _push(string $remote, string $branch = ''): \Horde\Components * @return SystemCallResult The result object * Might make sense to factor out into a git helper for reuse? */ - protected function _pushTags(string $remote, string $branch = ''): \Horde\Components\Component\Task\SystemCallResult + protected function _pushTags(string $remote, string $branch = ''): SystemCallResult { return $this->getShell()->exec( sprintf('git push --set-upstream %s %s --tags', $remote, $branch), diff --git a/src/Release/Task/Package.php b/src/Release/Task/Package.php index 002181e3..c0bc0093 100644 --- a/src/Release/Task/Package.php +++ b/src/Release/Task/Package.php @@ -13,6 +13,10 @@ use Horde\Components\Exception; use Horde\Components\Helper\Version as HelperVersion; +use Archive_Tar; +use Horde_Http_Exception; +use Horde_Pear_Remote; +use Horde_Util; /** * Components_Release_Task_Package:: prepares and uploads a release package. @@ -54,8 +58,8 @@ public function skip($options): bool public function preValidate($options): array { $errors = []; - $testpkg = \Horde_Util::getTempFile(); - $archive = new \Archive_Tar($testpkg, 'gz'); + $testpkg = Horde_Util::getTempFile(); + $archive = new Archive_Tar($testpkg, 'gz'); $archive->addString('a', 'a'); $archive->addString('b', 'b'); $results = exec('tar tzvf ' . $testpkg . ' 2>&1'); @@ -75,7 +79,7 @@ public function preValidate($options): array return $errors; } - $remote = new \Horde_Pear_Remote($options['releaseserver']); + $remote = new Horde_Pear_Remote($options['releaseserver']); try { $exists = $remote->releaseExists( $this->getComponent()->getName(), @@ -88,7 +92,7 @@ public function preValidate($options): array $this->getComponent()->getName() ); } - } catch (\Horde_Http_Exception $e) { + } catch (Horde_Http_Exception $e) { $errors[] = 'Failed accessing the remote PEAR server.'; } try { diff --git a/src/Release/Task/Packagist.php b/src/Release/Task/Packagist.php index bea9889b..5da89676 100644 --- a/src/Release/Task/Packagist.php +++ b/src/Release/Task/Packagist.php @@ -19,6 +19,8 @@ namespace Horde\Components\Release\Task; +use Horde_Http_Client; + /** * Components_Release_Task_Packagist:: Notify Packagist of update * @@ -97,7 +99,7 @@ public function preValidate($options): array */ public function askDependencies(): array { - return ['http' => \Horde_Http_Client::class]; + return ['http' => Horde_Http_Client::class]; } /** diff --git a/src/Release/Task/Satis.php b/src/Release/Task/Satis.php index 1c6d539f..04560360 100644 --- a/src/Release/Task/Satis.php +++ b/src/Release/Task/Satis.php @@ -16,6 +16,9 @@ namespace Horde\Components\Release\Task; +use Horde_Date; +use Horde_Http_Client; + /** * Components_Release_Task_Satis:: Rebuild a satis repo * @@ -61,7 +64,7 @@ public function preValidate($options): array */ public function askDependencies(): array { - return ['http' => \Horde_Http_Client::class]; + return ['http' => Horde_Http_Client::class]; } /** @@ -84,7 +87,9 @@ public function run(&$options): void if ($base == 'horde') { $base = 'base'; } - $repo = $options['git_repo_base'] . $base . '.git'; + // Use new name, falling back to old name (both set in _options) + $repoBase = $options['scm.repo.base'] ?? $options['git_repo_base']; + $repo = $repoBase . $base . '.git'; if ($pretend) { $this->getOutput()->info( sprintf( @@ -145,7 +150,7 @@ public function run(&$options): void sprintf( 'git commit -m "Updated by %s release at %s"', $package->getName(), - (new \Horde_Date(time(), 'UTC'))->toJson() + (new Horde_Date(time(), 'UTC'))->toJson() ), $options['satis_outdir'] ); @@ -183,7 +188,10 @@ protected function _options($options): array $options['satis_outdir'] ??= ''; $options['satis_push'] = (bool) $options['satis_push']; $options['vendor'] ??= 'horde'; - $options['git_repo_base'] ??= 'https://github.com/' . $options['vendor'] . '/'; + // Try new name first, then fall back to old name for backwards compatibility + $options['scm.repo.base'] ??= $options['git_repo_base'] ?? 'https://github.com/' . $options['vendor'] . '/'; + // Keep old name populated for backwards compatibility + $options['git_repo_base'] ??= $options['scm.repo.base']; return $options; } } diff --git a/src/Release/Task/Sentinel.php b/src/Release/Task/Sentinel.php index f94277e1..d841af67 100644 --- a/src/Release/Task/Sentinel.php +++ b/src/Release/Task/Sentinel.php @@ -12,6 +12,8 @@ namespace Horde\Components\Release\Task; +use Horde_Release_Sentinel; + /** * Components_Release_Task_Sentinel:: provides base functionality for the * sentinel tasks. @@ -38,7 +40,7 @@ class Sentinel extends Base */ public function preValidate($options) { - if (!class_exists(\Horde_Release_Sentinel::class)) { + if (!class_exists(Horde_Release_Sentinel::class)) { return ['The \Horde_Release package is missing (specifically the class \Horde_Release_Sentinel)!']; } return []; diff --git a/src/Release/Task/Timestamp.php b/src/Release/Task/Timestamp.php index 13dced90..205f2f5a 100644 --- a/src/Release/Task/Timestamp.php +++ b/src/Release/Task/Timestamp.php @@ -13,6 +13,7 @@ namespace Horde\Components\Release\Task; use Horde\Components\Exception; +use Horde_Exception_NotFound; /** * Components_Release_Task_Timestamp:: timestamps the package right before the @@ -38,7 +39,7 @@ class Timestamp extends Base * @return array An empty array if all preconditions are met and a list of * error messages otherwise. * @throws Exception - * @throws \Horde_Exception_NotFound + * @throws Horde_Exception_NotFound */ public function preValidate($options): array { diff --git a/src/Release/Task/Website.php b/src/Release/Task/Website.php index f0f5880f..d4959ef0 100644 --- a/src/Release/Task/Website.php +++ b/src/Release/Task/Website.php @@ -14,6 +14,9 @@ namespace Horde\Components\Release\Task; +use Horde_Release_Website; +use PDO; + /** * Components_Release_Task_Website adds the new release to the Horde website. * @@ -27,7 +30,7 @@ class Website extends Base /** * Database handle. * - * @var \PDO + * @var PDO */ protected $_db; @@ -42,13 +45,16 @@ class Website extends Base public function preValidate($options): array { $errors = []; - if (empty($options['web_dir'])) { - $errors[] = 'The "web" option has no value. Where is the local checkout of the horde-web repository?'; - } elseif (!file_exists($options['web_dir'] . '/config/versions.sqlite') - || !is_writable($options['web_dir'] . '/config/versions.sqlite')) { - $errors[] = 'The database at ' . $options['web_dir'] . '/config/versions.sqlite doesn\'t exist or is not writable'; + // Try new name first, then fall back to old name for backwards compatibility + $webDir = $options['hordeweb.dir'] ?? $options['web_dir'] ?? null; + + if (empty($webDir)) { + $errors[] = 'The "hordeweb.dir" option has no value. Where is the local checkout of the horde-web repository?'; + } elseif (!file_exists($webDir . '/config/versions.sqlite') + || !is_writable($webDir . '/config/versions.sqlite')) { + $errors[] = 'The database at ' . $webDir . '/config/versions.sqlite doesn\'t exist or is not writable'; } else { - $this->_db = new \PDO('sqlite:' . $options['web_dir'] . '/config/versions.sqlite'); + $this->_db = new PDO('sqlite:' . $webDir . '/config/versions.sqlite'); } return $errors; } @@ -79,7 +85,7 @@ public function run(&$options): void ) ); } else { - $website = new \Horde_Release_Website($this->_db); + $website = new Horde_Release_Website($this->_db); $website->addNewVersion(['application' => $module, 'version' => $version]); } } diff --git a/src/Release/Tasks.php b/src/Release/Tasks.php index 52cfd0b1..6b5c5af2 100644 --- a/src/Release/Tasks.php +++ b/src/Release/Tasks.php @@ -17,6 +17,7 @@ use Horde\Components\Exception; use Horde\Components\Helper\Commit as HelperCommit; use Horde\Components\Release\Task\Base as TaskBase; +use Horde_Exception; /** * Components_Release_Tasks:: organizes the different tasks required for @@ -70,7 +71,7 @@ public function getTask($name, Component $component): TaskBase foreach ($task->askDependencies() as $key => $dependency) { try { $deps[$key] = $this->_dependencies->getInstance($dependency); - } catch (\Horde_Exception) { + } catch (Horde_Exception) { // what to do here? } } diff --git a/src/Runner/ConventionalCommit.php b/src/Runner/ConventionalCommit.php index 00dc6131..4b1fe980 100644 --- a/src/Runner/ConventionalCommit.php +++ b/src/Runner/ConventionalCommit.php @@ -19,6 +19,7 @@ use Horde\Components\Helper\Git as GitHelper; use Horde\Components\Helper\Version as VersionHelper; use Horde\Components\ConventionalCommitReader; +use InvalidArgumentException; /** * Components_Runner_Change:: adds a new change log entry. @@ -122,7 +123,7 @@ public function run(): void $this->runNextVersion(); break; default: - throw new \InvalidArgumentException('Unknown action: ' . $action); + throw new InvalidArgumentException('Unknown action: ' . $action); } } diff --git a/src/Runner/Database/ConfigureRunner.php b/src/Runner/Database/ConfigureRunner.php new file mode 100644 index 00000000..7641801d --- /dev/null +++ b/src/Runner/Database/ConfigureRunner.php @@ -0,0 +1,434 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Runner\Database; + +use Horde\Components\Output; +use Horde\Components\ConfigProvider\PhpConfigFileProvider; +use Horde\Components\Helper\Database\DatabaseManager; +use Horde_Cli; +use Exception; + +/** + * Configure Runner - Interactive database connection configuration + * + * Prompts user for database connection parameters and saves to config file. + * Supports SQLite, MySQL, and PostgreSQL with appropriate parameter sets. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class ConfigureRunner +{ + private const SUPPORTED_TYPES = ['sqlite', 'mysql', 'postgresql']; + private const DEFAULT_TYPE = 'sqlite'; + + /** + * Constructor. + * + * @param Horde_Cli $cli CLI interaction handler + * @param Output $output Output handler + * @param PhpConfigFileProvider $config Configuration provider + */ + public function __construct( + private readonly Horde_Cli $cli, + private readonly Output $output, + private readonly PhpConfigFileProvider $config + ) {} + + /** + * Run configure subcommand. + * + * @param array $options CLI options + * @return int Exit code (0 = success, 1 = error) + */ + public function run(array $options = []): int + { + $this->output->bold('=== Database Configuration ==='); + $this->output->plain(''); + + // Show info about multi-database support + $this->output->info('You can configure multiple database types for comprehensive testing'); + $this->output->info('Each database type is stored separately in config'); + $this->output->plain(''); + + // Show security warning + $this->showSecurityWarning(); + + // Get database type from option or prompt + $type = $this->getDatabaseType($options); + + // Prompt for parameters based on type + $result = ($type === 'sqlite') + ? $this->configureSqlite() + : $this->configureServer($type); + + if ($result === 0) { + // Ask if they want to configure another database type + $this->output->plain(''); + return $this->promptConfigureAnother(); + } + + return $result; + } + + /** + * Get database type from options or prompt. + * + * @param array $options CLI options + * @return string Database type + */ + private function getDatabaseType(array $options): string + { + // Check if --db-type option provided (Horde_Argv uses underscores in keys) + if (!empty($options['db_type'])) { + $type = strtolower($options['db_type']); + + // Validate type + if (!in_array($type, self::SUPPORTED_TYPES)) { + $this->output->fail("Invalid database type: {$type}"); + $this->output->info('Valid types: ' . implode(', ', self::SUPPORTED_TYPES)); + exit(1); + } + + $this->output->info("Using database type from --db-type: {$type}"); + $this->output->plain(''); + return $type; + } + + // Prompt for database type + return $this->promptDatabaseType(); + } + + /** + * Show security warning about password storage. + */ + private function showSecurityWarning(): void + { + $this->output->warn('Passwords will be stored in plain text in config file'); + $this->output->info('File permissions: 0600 (readable only by you)'); + $this->output->info('Location: ~/.config/horde/components.php'); + $this->output->plain(''); + } + + /** + * Prompt for database type. + * + * @return string Selected database type + */ + private function promptDatabaseType(): string + { + // Check which databases are already configured + $configured = []; + foreach (self::SUPPORTED_TYPES as $type) { + if ($this->config->hasSetting("database.{$type}.name")) { + $configured[] = $type; + } + } + + if (!empty($configured)) { + $this->output->info('Already configured: ' . implode(', ', $configured)); + $this->output->plain(''); + } + + // Default to first unconfigured, or sqlite + $defaultType = self::DEFAULT_TYPE; + foreach (self::SUPPORTED_TYPES as $type) { + if (!in_array($type, $configured)) { + $defaultType = $type; + break; + } + } + + // Find default index + $defaultIndex = array_search($defaultType, self::SUPPORTED_TYPES); + if ($defaultIndex === false) { + $defaultIndex = 0; + } + + $choice = $this->cli->prompt( + 'Database type to configure [sqlite/mysql/postgresql]:', + self::SUPPORTED_TYPES, + $defaultIndex + ); + + return self::SUPPORTED_TYPES[$choice]; + } + + /** + * Configure SQLite database. + * + * @return int Exit code + */ + private function configureSqlite(): int + { + $this->output->info('Configuring SQLite database...'); + $this->output->plain(''); + + // Database name (file name without extension) + $currentName = $this->config->hasSetting('database.sqlite.name') + ? $this->config->getSetting('database.sqlite.name') + : 'horde_ci'; + + $name = $this->cli->prompt('Database name:', null, $currentName); + $name = trim($name); + + // Database path (full path to .sqlite file) + $defaultPath = getcwd() . '/build/' . $name . '.sqlite'; + $currentPath = $this->config->hasSetting('database.sqlite.path') + ? $this->config->getSetting('database.sqlite.path') + : $defaultPath; + + $path = $this->cli->prompt('SQLite file path:', null, $currentPath); + $path = trim($path); + + // Ensure directory exists + $dir = dirname($path); + if (!is_dir($dir)) { + if (!mkdir($dir, 0o755, true)) { + $this->output->fail("Failed to create directory: {$dir}"); + return 1; + } + $this->output->ok("Created directory: {$dir}"); + } + + // Table prefix + $prefix = $this->promptTablePrefix('sqlite'); + + // Save to config with new structure + $this->config->setSetting('database.sqlite.name', $name); + $this->config->setSetting('database.sqlite.path', $path); + $this->config->setSetting('database.sqlite.prefix', $prefix); + + $this->config->writeToDisk(); + + $this->output->plain(''); + $this->output->ok('SQLite configuration saved'); + $this->showSavedConfig('sqlite'); + + // Test connection + return $this->testConnection('sqlite'); + } + + /** + * Configure MySQL or PostgreSQL database. + * + * @param string $type Database type (mysql or postgresql) + * @return int Exit code + */ + private function configureServer(string $type): int + { + $this->output->info("Configuring {$type} database..."); + $this->output->plain(''); + + // Database name + $currentName = $this->config->hasSetting("database.{$type}.name") + ? $this->config->getSetting("database.{$type}.name") + : 'horde_ci'; + + $name = $this->cli->prompt('Database name:', null, $currentName); + $name = trim($name); + + // Host + $currentHost = $this->config->hasSetting("database.{$type}.host") + ? $this->config->getSetting("database.{$type}.host") + : 'localhost'; + + $host = $this->cli->prompt('Host:', null, $currentHost); + $host = trim($host); + + // Port + $defaultPort = $type === 'mysql' ? '3306' : '5432'; + $currentPort = $this->config->hasSetting("database.{$type}.port") + ? $this->config->getSetting("database.{$type}.port") + : $defaultPort; + + $port = $this->cli->prompt('Port:', null, $currentPort); + $port = trim($port); + + // Validate port is numeric + if (!ctype_digit($port)) { + $this->output->fail("Port must be numeric: {$port}"); + return 1; + } + + // Table prefix + $prefix = $this->promptTablePrefix($type); + + // Save to config with new structure + $this->config->setSetting("database.{$type}.name", $name); + $this->config->setSetting("database.{$type}.host", $host); + $this->config->setSetting("database.{$type}.port", $port); + $this->config->setSetting("database.{$type}.prefix", $prefix); + + $this->config->writeToDisk(); + + $this->output->plain(''); + $this->output->ok("{$type} configuration saved"); + $this->showSavedConfig($type); + + // Test connection + return $this->testConnection($type); + } + + /** + * Prompt for table prefix. + * + * @param string $type Database type + * @return string Table prefix + */ + private function promptTablePrefix(string $type): string + { + $currentPrefix = $this->config->hasSetting("database.{$type}.prefix") + ? $this->config->getSetting("database.{$type}.prefix") + : 'test_components_'; + + $prefix = $this->cli->prompt('Table prefix:', null, $currentPrefix); + return trim($prefix); + } + + /** + * Show saved configuration summary. + * + * @param string $type Database type + */ + private function showSavedConfig(string $type): void + { + $this->output->plain(''); + $this->output->bold('Configuration Summary:'); + + $this->output->plain(" Type: {$type}"); + + $name = $this->config->getSetting("database.{$type}.name"); + $this->output->plain(" Name: {$name}"); + + if ($type === 'sqlite') { + $path = $this->config->getSetting("database.{$type}.path"); + $this->output->plain(" Path: {$path}"); + } else { + $host = $this->config->getSetting("database.{$type}.host"); + $port = $this->config->getSetting("database.{$type}.port"); + $this->output->plain(" Host: {$host}"); + $this->output->plain(" Port: {$port}"); + } + + $prefix = $this->config->getSetting("database.{$type}.prefix"); + $this->output->plain(" Prefix: {$prefix}"); + + $this->output->plain(''); + } + + /** + * Test database connection. + * + * @param string $type Database type + * @return int Exit code (0 = success, 1 = error) + */ + private function testConnection(string $type): int + { + $this->output->info('Testing database connection...'); + + try { + $manager = new DatabaseManager($this->config, $this->output, $type); + + if ($manager->testConnection()) { + $this->output->ok('Connection successful! ✓'); + $this->output->plain(''); + $this->output->info('Next steps:'); + $this->output->info(' 1. Configure users: horde-components database user ' . $type); + $this->output->info(' 2. Install database: horde-components database install'); + return 0; + } else { + $this->output->warn('Connection test returned false'); + $this->showTroubleshooting($type); + return 1; + } + } catch (Exception $e) { + $this->output->fail('Connection failed: ' . $e->getMessage()); + $this->showTroubleshooting($type); + return 1; + } + } + + /** + * Show troubleshooting tips. + * + * @param string $type Database type + */ + private function showTroubleshooting(string $type): void + { + $this->output->plain(''); + $this->output->bold('Troubleshooting:'); + + if ($type === 'sqlite') { + $this->output->info(' 1. Check directory is writable'); + $this->output->info(' 2. Ensure sqlite3 extension is installed: php -m | grep sqlite3'); + $path = $this->config->getSetting("database.{$type}.path"); + $this->output->info(" 3. Try creating file manually: touch {$path}"); + } else { + $this->output->info(' 1. Check database server is running'); + + if ($type === 'mysql') { + $this->output->info(' systemctl status mysql'); + } else { + $this->output->info(' systemctl status postgresql'); + } + + $this->output->info(' 2. Verify connection manually:'); + + $host = $this->config->getSetting("database.{$type}.host"); + $name = $this->config->getSetting("database.{$type}.name"); + + if ($type === 'mysql') { + $this->output->info(" mysql -h {$host} -u root -p {$name}"); + } else { + $this->output->info(" psql -h {$host} -U postgres {$name}"); + } + + $this->output->info(' 3. Ensure PHP extension is installed:'); + $ext = $type === 'mysql' ? 'mysqli' : 'pgsql'; + $this->output->info(" php -m | grep {$ext}"); + } + + $this->output->info(' 4. Check config: cat ~/.config/horde/components.php'); + } + + /** + * Prompt to configure another database type. + * + * @return int Exit code + */ + private function promptConfigureAnother(): int + { + $choices = ['y', 'n']; + $response = $this->cli->prompt( + 'Configure another database type? [y/n]:', + $choices, + 1 // Default to 'n' + ); + + if ($choices[$response] === 'y') { + $this->output->plain(''); + return $this->run(); // Recursively run again + } + + return 0; + } +} diff --git a/src/Runner/Database/DbInstallRunner.php b/src/Runner/Database/DbInstallRunner.php new file mode 100644 index 00000000..88ae458c --- /dev/null +++ b/src/Runner/Database/DbInstallRunner.php @@ -0,0 +1,613 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Runner\Database; + +use Horde\Components\Output; +use Horde\Components\ConfigProvider\PhpConfigFileProvider; +use Horde_Cli; + +/** + * DbInstall Runner - Generate and execute database installation script + * + * Generates idempotent Ubuntu 24.04 installation script with embedded + * credentials. Script installs database software, PHP extensions, and + * creates configured users. Self-deletes after execution. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class DbInstallRunner +{ + /** + * Constructor. + * + * @param Horde_Cli $cli CLI interaction handler + * @param Output $output Output handler + * @param PhpConfigFileProvider $config Configuration provider + */ + public function __construct( + private readonly Horde_Cli $cli, + private readonly Output $output, + private readonly PhpConfigFileProvider $config + ) {} + + /** + * Run install subcommand. + * + * @param array $options CLI options + * @return int Exit code (0 = success, 1 = error) + */ + public function run(array $options = []): int + { + $this->output->bold('=== Database Installation ==='); + $this->output->plain(''); + + // Detect configured databases + $configured = $this->getConfiguredDatabases(); + + if (empty($configured)) { + $this->output->fail('No databases configured. Run: horde-components database configure'); + return 1; + } + + // Check if --db-type option provided + if (!empty($options['db_type'])) { + $type = strtolower($options['db_type']); + + // Validate type + if (!in_array($type, ['sqlite', 'mysql', 'postgresql'])) { + $this->output->fail("Invalid database type: {$type}"); + $this->output->info('Valid types: sqlite, mysql, postgresql'); + return 1; + } + + // Check if configured + if (!in_array($type, $configured)) { + $this->output->fail("{$type} database not configured. Run: horde-components database configure --db-type={$type}"); + return 1; + } + + $this->output->info("Installing specific database: {$type}"); + $this->output->plain(''); + + return $this->installDatabase($type); + } + + // Install all configured databases + $this->output->info('Configured databases: ' . implode(', ', $configured)); + $this->output->plain(''); + + $results = []; + foreach ($configured as $type) { + $this->output->bold("--- Installing {$type} ---"); + $this->output->plain(''); + + $result = $this->installDatabase($type); + $results[$type] = $result; + + $this->output->plain(''); + } + + // Show summary + $this->output->bold('=== Installation Summary ==='); + $this->output->plain(''); + + $allSuccess = true; + foreach ($results as $type => $exitCode) { + if ($exitCode === 0) { + $this->output->ok("{$type}: Success ✓"); + } else { + $this->output->fail("{$type}: Failed ✗"); + $allSuccess = false; + } + } + + return $allSuccess ? 0 : 1; + } + + /** + * Get list of configured database types. + * + * @return array List of configured database types + */ + private function getConfiguredDatabases(): array + { + $configured = []; + $types = ['sqlite', 'mysql', 'postgresql']; + + foreach ($types as $type) { + if ($this->config->hasSetting("database.{$type}.name")) { + $configured[] = $type; + } + } + + return $configured; + } + + /** + * Install a specific database type. + * + * @param string $type Database type + * @return int Exit code + */ + private function installDatabase(string $type): int + { + // SQLite doesn't need installation script + if ($type === 'sqlite') { + return $this->handleSqlite($type); + } + + // Generate installation script + $this->output->info('Generating idempotent installation script for Ubuntu 24.04...'); + $this->output->plain(''); + + $script = $this->generateInstallScript($type); + $scriptPath = $this->saveScript($script, $type); + + if ($scriptPath === null) { + $this->output->fail('Failed to save installation script'); + return 1; + } + + $this->output->ok("Installation script generated: {$scriptPath}"); + $this->output->plain(''); + + // Check if we have sudo + return $this->executeOrPrint($scriptPath); + } + + /** + * Handle SQLite installation. + * + * @param string $type Database type (should be 'sqlite') + * @return int Exit code + */ + private function handleSqlite(string $type): int + { + $this->output->info('SQLite installation check...'); + $this->output->plain(''); + + // Check if sqlite3 PHP extension is loaded + if (extension_loaded('sqlite3') || extension_loaded('pdo_sqlite')) { + $this->output->ok('SQLite support is already installed ✓'); + $this->output->plain(''); + + // Check if database file exists + $path = $this->config->getSetting("database.{$type}.path"); + if (file_exists($path)) { + $this->output->ok("Database file exists: {$path}"); + } else { + $dir = dirname($path); + if (!is_dir($dir)) { + if (mkdir($dir, 0o755, true)) { + $this->output->ok("Created directory: {$dir}"); + } else { + $this->output->fail("Failed to create directory: {$dir}"); + return 1; + } + } + $this->output->info("Database file will be created on first connection"); + } + + return 0; + } + + // Need to install SQLite + $this->output->warn('SQLite PHP extension not found'); + $this->output->plain(''); + $this->output->info('Install with: sudo apt-get install php-sqlite3'); + + return 1; + } + + /** + * Generate installation script for database type. + * + * @param string $type Database type (mysql or postgresql) + * @return string Shell script content + */ + private function generateInstallScript(string $type): string + { + $header = $this->generateHeader($type); + $detection = $this->generateOsDetection(); + + if ($type === 'mysql') { + $install = $this->generateMySQLInstall(); + $users = $this->generateMySQLUsers($type); + } else { + $install = $this->generatePostgreSQLInstall(); + $users = $this->generatePostgreSQLUsers($type); + } + + $footer = $this->generateFooter(); + + return $header . $detection . $install . $users . $footer; + } + + /** + * Generate script header. + * + * @param string $type Database type + * @return string Header content + */ + private function generateHeader(string $type): string + { + $timestamp = date('Y-m-d H:i:s'); + + return << Installing MySQL Server..." + + # Check if already installed + if command -v mysql >/dev/null 2>&1; then + echo "MySQL already installed: $(mysql --version | head -n1)" + else + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq mysql-server + echo "MySQL installed successfully" + fi + + echo "" + echo "==> Installing PHP MySQL Extension..." + + # Check if extension is loaded + if php -m | grep -q mysqli; then + echo "PHP mysqli extension already loaded" + else + apt-get install -y -qq php-mysql + echo "PHP mysqli extension installed" + fi + + echo "" + echo "==> Starting MySQL Service..." + systemctl enable mysql >/dev/null 2>&1 || true + systemctl start mysql || true + systemctl status mysql --no-pager | head -n 5 + + echo "" + + + BASH +. "\n"; + } + + /** + * Generate PostgreSQL installation section. + * + * @return string PostgreSQL installation code + */ + private function generatePostgreSQLInstall(): string + { + return <<<'BASH' + # Install PostgreSQL Server and PHP Extension + echo "==> Installing PostgreSQL Server..." + + # Check if already installed + if command -v psql >/dev/null 2>&1; then + echo "PostgreSQL already installed: $(psql --version)" + else + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq postgresql postgresql-contrib + echo "PostgreSQL installed successfully" + fi + + echo "" + echo "==> Installing PHP PostgreSQL Extension..." + + # Check if extension is loaded + if php -m | grep -q pgsql; then + echo "PHP pgsql extension already loaded" + else + apt-get install -y -qq php-pgsql + echo "PHP pgsql extension installed" + fi + + echo "" + echo "==> Starting PostgreSQL Service..." + systemctl enable postgresql >/dev/null 2>&1 || true + systemctl start postgresql || true + systemctl status postgresql --no-pager | head -n 5 + + echo "" + + + BASH +. "\n"; + } + + /** + * Generate MySQL user creation section. + * + * @param string $type Database type + * @return string MySQL user creation code + */ + private function generateMySQLUsers(string $type): string + { + $dbName = $this->config->getSetting("database.{$type}.name"); + $userSQL = ''; + + // Check for configured users + $levels = ['admin', 'power', 'restricted']; + $hasUsers = false; + + foreach ($levels as $level) { + if ($this->config->hasSetting("database.{$type}.user.{$level}.username")) { + $hasUsers = true; + $username = $this->config->getSetting("database.{$type}.user.{$level}.username"); + $password = $this->config->getSetting("database.{$type}.user.{$level}.password"); + + $privileges = match ($level) { + 'admin' => 'ALL PRIVILEGES', + 'power' => 'SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER', + 'restricted' => 'SELECT, INSERT, UPDATE, DELETE', + }; + + $userSQL .= "CREATE USER IF NOT EXISTS '{$username}'@'localhost' IDENTIFIED BY '{$password}';\n"; + $userSQL .= "GRANT {$privileges} ON \`{$dbName}\`.* TO '{$username}'@'localhost';\n"; + } + } + + if (!$hasUsers) { + return <<<'BASH' + # No users configured + echo "==> No database users configured" + echo " Run: horde-components database user" + echo "" + + + BASH +. "\n"; + } + + $userSQL .= "FLUSH PRIVILEGES;\n"; + + return << Creating Database: {$dbName}..." + + mysql -u root <<'EOSQL' + CREATE DATABASE IF NOT EXISTS \`{$dbName}\`; + {$userSQL} + EOSQL + + echo "Database and users created successfully" + echo "" + + + BASH +. "\n"; + } + + /** + * Generate PostgreSQL user creation section. + * + * @param string $type Database type + * @return string PostgreSQL user creation code + */ + private function generatePostgreSQLUsers(string $type): string + { + $dbName = $this->config->getSetting("database.{$type}.name"); + $userSQL = ''; + + // Check for configured users + $levels = ['admin', 'power', 'restricted']; + $hasUsers = false; + + foreach ($levels as $level) { + if ($this->config->hasSetting("database.{$type}.user.{$level}.username")) { + $hasUsers = true; + $username = $this->config->getSetting("database.{$type}.user.{$level}.username"); + $password = $this->config->getSetting("database.{$type}.user.{$level}.password"); + + $createOptions = match ($level) { + 'admin' => 'CREATEDB CREATEROLE', + 'power' => 'NOCREATEDB NOCREATEROLE', + 'restricted' => 'NOCREATEDB NOCREATEROLE', + }; + + // PostgreSQL: Create user if not exists + $userSQL .= "SELECT 'CREATE USER {$username} WITH PASSWORD ''{$password}'' {$createOptions}'\n"; + $userSQL .= "WHERE NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = '{$username}')" . '\\' . "gexec\n"; + + if ($level === 'admin') { + $userSQL .= "GRANT ALL PRIVILEGES ON DATABASE {$dbName} TO {$username};\n"; + } elseif ($level === 'power') { + $userSQL .= "GRANT CONNECT ON DATABASE {$dbName} TO {$username};\n"; + $userSQL .= "GRANT CREATE ON SCHEMA public TO {$username};\n"; + } else { + $userSQL .= "GRANT CONNECT ON DATABASE {$dbName} TO {$username};\n"; + } + } + } + + if (!$hasUsers) { + return <<<'BASH' + # No users configured + echo "==> No database users configured" + echo " Run: horde-components database user" + echo "" + + + BASH +. "\n"; + } + + return << Creating Database: {$dbName}..." + + sudo -u postgres psql <<'EOSQL' + CREATE DATABASE {$dbName}; + {$userSQL} + EOSQL + + echo "Database and users created successfully" + echo "" + + + BASH +. "\n"; + } + + /** + * Generate script footer with self-deletion. + * + * @return string Footer content + */ + private function generateFooter(): string + { + return <<<'BASH' + # Self-delete + echo "==> Installation complete!" + echo " Deleting installation script (contains credentials)..." + rm -f "$SCRIPT_PATH" + echo " Script deleted." + + + BASH +. "\n"; + } + + /** + * Save script to temporary file. + * + * @param string $script Script content + * @param string $type Database type + * @return string|null Path to script file, or null on failure + */ + private function saveScript(string $script, string $type): ?string + { + $tmpDir = sys_get_temp_dir(); + $scriptPath = $tmpDir . '/horde-components-db-install-' . $type . '-' . uniqid() . '.sh'; + + if (file_put_contents($scriptPath, $script) === false) { + return null; + } + + chmod($scriptPath, 0o700); // Make executable, readable only by owner + + return $scriptPath; + } + + /** + * Execute script if we have sudo, otherwise print instructions. + * + * @param string $scriptPath Path to script + * @return int Exit code + */ + private function executeOrPrint(string $scriptPath): int + { + // Check if we have sudo + exec('sudo -n true 2>/dev/null', $output, $exitCode); + + if ($exitCode === 0) { + // We have passwordless sudo + $this->output->info('Executing installation script with sudo...'); + $this->output->plain(''); + + passthru("sudo bash {$scriptPath}", $installExit); + + if ($installExit === 0) { + $this->output->plain(''); + $this->output->ok('Installation completed successfully'); + } else { + $this->output->plain(''); + $this->output->fail("Installation failed with exit code: {$installExit}"); + } + + return $installExit; + } + + // No sudo - print instructions + $this->output->warn('Sudo access required to install database software'); + $this->output->plain(''); + $this->output->info('Run the following command with sudo:'); + $this->output->plain(''); + $this->output->plain(" sudo bash {$scriptPath}"); + $this->output->plain(''); + $this->output->warn("Note: Script contains credentials and will self-delete after execution"); + + return 0; + } +} diff --git a/src/Runner/Database/DropRunner.php b/src/Runner/Database/DropRunner.php new file mode 100644 index 00000000..81d85cc5 --- /dev/null +++ b/src/Runner/Database/DropRunner.php @@ -0,0 +1,314 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Runner\Database; + +use Horde\Components\Output; +use Horde\Components\ConfigProvider\PhpConfigFileProvider; +use Horde\Components\Helper\Database\DatabaseManager; +use Horde_Cli; +use Exception; + +/** + * Drop Runner - Drop database tables matching prefix + * + * Lists tables matching configured prefix and prompts for confirmation + * before dropping them. Safety-first approach for cleaning up test data. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class DropRunner +{ + /** + * Constructor. + * + * @param Horde_Cli $cli CLI interaction handler + * @param Output $output Output handler + * @param PhpConfigFileProvider $config Configuration provider + */ + public function __construct( + private readonly Horde_Cli $cli, + private readonly Output $output, + private readonly PhpConfigFileProvider $config + ) {} + + /** + * Run drop subcommand. + * + * @param array $options CLI options + * @return int Exit code (0 = success, 1 = error) + */ + public function run(array $options = []): int + { + $this->output->bold('=== Drop Database Tables ==='); + $this->output->plain(''); + + // Detect configured databases + $configured = $this->getConfiguredDatabases(); + + if (empty($configured)) { + $this->output->fail('No databases configured. Run: horde-components database configure'); + return 1; + } + + // Get database type from option or prompt + $type = $this->getDatabaseType($options, $configured); + + // Get prefix from config + if (!$this->config->hasSetting("database.{$type}.prefix")) { + $this->output->fail("Table prefix not configured for {$type}. Run: horde-components database configure"); + return 1; + } + + $prefix = $this->config->getSetting("database.{$type}.prefix"); + + // Create database manager + try { + $manager = new DatabaseManager($this->config, $this->output, $type); + } catch (Exception $e) { + $this->output->fail('Failed to create database connection: ' . $e->getMessage()); + return 1; + } + + // Test connection + if (!$manager->testConnection()) { + $this->output->fail('Cannot connect to database. Check configuration and credentials.'); + $this->output->info('Run: horde-components database configure'); + return 1; + } + + // List tables matching prefix + $this->output->info("Searching for tables with prefix: {$prefix}"); + $this->output->plain(''); + + try { + $tables = $manager->listTables($prefix); + } catch (Exception $e) { + $this->output->fail('Failed to list tables: ' . $e->getMessage()); + return 1; + } + + if (empty($tables)) { + $this->output->ok('No tables found with prefix: ' . $prefix); + $this->output->plain(''); + $this->output->info('Database is clean - nothing to drop'); + return 0; + } + + // Show tables to be dropped + $this->output->warn('Found ' . count($tables) . ' table(s) to drop:'); + $this->output->plain(''); + + foreach ($tables as $table) { + $this->output->plain(" - {$table}"); + } + + $this->output->plain(''); + + // Show warning + $this->showWarning(); + + // Confirm deletion + if (!$this->confirmDrop($tables)) { + $this->output->info('Operation cancelled - no tables were dropped'); + return 0; + } + + // Drop tables + return $this->dropTables($manager, $tables); + } + + /** + * Get database type from options or prompt. + * + * @param array $options CLI options + * @param array $configured List of configured databases + * @return string Database type + */ + private function getDatabaseType(array $options, array $configured): string + { + // Check if --db-type option provided + if (!empty($options['db_type'])) { + $type = strtolower($options['db_type']); + + // Validate type + if (!in_array($type, ['sqlite', 'mysql', 'postgresql'])) { + $this->output->fail("Invalid database type: {$type}"); + $this->output->info('Valid types: sqlite, mysql, postgresql'); + exit(1); + } + + // Check if configured + if (!in_array($type, $configured)) { + $this->output->fail("{$type} database not configured. Run: horde-components database configure --db-type={$type}"); + exit(1); + } + + $this->output->info("Using database type from --db-type: {$type}"); + $this->output->plain(''); + return $type; + } + + // Prompt for database type + return $this->promptDatabaseType($configured); + } + + /** + * Show warning about destructive operation. + */ + private function showWarning(): void + { + $this->output->warn('⚠️ WARNING: This operation is DESTRUCTIVE and IRREVERSIBLE'); + $this->output->warn('⚠️ All data in these tables will be permanently deleted'); + $this->output->plain(''); + } + + /** + * Confirm table drop with user. + * + * @param array $tables List of tables to drop + * @return bool True if user confirmed + */ + private function confirmDrop(array $tables): bool + { + $tableCount = count($tables); + + $this->output->info('Type the number of tables to confirm deletion:'); + $expectedInput = (string) $tableCount; + + $response = $this->cli->prompt("Type '{$expectedInput}' to confirm", null, ''); + $response = trim($response); + + if ($response !== $expectedInput) { + return false; + } + + // Double confirmation for large numbers + if ($tableCount > 10) { + $this->output->plain(''); + $this->output->warn('You are about to drop ' . $tableCount . ' tables'); + + $choices = ['y', 'n']; + $confirm = $this->cli->prompt( + 'Are you absolutely sure? [y/n]:', + $choices, + 1 // Default to 'n' + ); + + return $choices[$confirm] === 'y'; + } + + return true; + } + + /** + * Drop tables from database. + * + * @param DatabaseManager $manager Database manager + * @param array $tables List of tables to drop + * @return int Exit code + */ + private function dropTables(DatabaseManager $manager, array $tables): int + { + $this->output->plain(''); + $this->output->info('Dropping tables...'); + $this->output->plain(''); + + $dropped = 0; + $failed = 0; + + foreach ($tables as $table) { + $this->output->plain("Dropping: {$table}...", false); + + if ($manager->dropTable($table)) { + $this->output->ok(' ✓'); + $dropped++; + } else { + $this->output->fail(' ✗'); + $failed++; + } + } + + $this->output->plain(''); + + // Show summary + if ($failed === 0) { + $this->output->ok("Successfully dropped {$dropped} table(s)"); + return 0; + } elseif ($dropped > 0) { + $this->output->warn("Dropped {$dropped} table(s), {$failed} failed"); + return 1; + } else { + $this->output->fail("Failed to drop all {$failed} table(s)"); + return 1; + } + } + + /** + * Get list of configured database types. + * + * @return array List of configured database types + */ + private function getConfiguredDatabases(): array + { + $configured = []; + $types = ['sqlite', 'mysql', 'postgresql']; + + foreach ($types as $type) { + if ($this->config->hasSetting("database.{$type}.name")) { + $configured[] = $type; + } + } + + return $configured; + } + + /** + * Prompt for database type to drop tables from. + * + * @param array $configured List of configured database types + * @return string Selected database type + */ + private function promptDatabaseType(array $configured): string + { + // If only one database configured, use it + if (count($configured) === 1) { + $type = $configured[0]; + $this->output->info("Using configured database: {$type}"); + $this->output->plain(''); + return $type; + } + + // Multiple databases - prompt user + $this->output->warn('Multiple databases configured - choose carefully!'); + $this->output->info('Databases: ' . implode(', ', $configured)); + $this->output->plain(''); + + $defaultIndex = 0; + $choice = $this->cli->prompt( + 'Which database to drop tables from? [' . implode('/', $configured) . ']:', + $configured, + $defaultIndex + ); + + return $configured[$choice]; + } +} diff --git a/src/Runner/Database/UserRunner.php b/src/Runner/Database/UserRunner.php new file mode 100644 index 00000000..16aefe46 --- /dev/null +++ b/src/Runner/Database/UserRunner.php @@ -0,0 +1,552 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Runner\Database; + +use Horde\Components\Output; +use Horde\Components\ConfigProvider\PhpConfigFileProvider; +use Horde_Cli; + +/** + * User Runner - Interactive database user configuration + * + * Prompts for database user credentials with three privilege levels: + * - admin: Full database control (ALL PRIVILEGES) + * - power: Schema modification (DDL + DML) + * - restricted: Data access only (DML) + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class UserRunner +{ + private const PRIVILEGE_LEVELS = ['admin', 'power', 'restricted']; + + private const PRIVILEGE_DESCRIPTIONS = [ + 'admin' => 'Full database control (ALL PRIVILEGES) - for setup and migrations', + 'power' => 'Schema modification (DDL + DML) - for tests that create/drop tables', + 'restricted' => 'Data access only (DML) - for tests that only read/write data', + ]; + + /** + * Constructor. + * + * @param Horde_Cli $cli CLI interaction handler + * @param Output $output Output handler + * @param PhpConfigFileProvider $config Configuration provider + */ + public function __construct( + private readonly Horde_Cli $cli, + private readonly Output $output, + private readonly PhpConfigFileProvider $config + ) {} + + /** + * Run user configuration subcommand. + * + * @param array $options CLI options + * @return int Exit code (0 = success, 1 = error) + */ + public function run(array $options = []): int + { + $this->output->bold('=== Database User Configuration ==='); + $this->output->plain(''); + + // Detect configured databases + $configured = $this->getConfiguredDatabases(); + + if (empty($configured)) { + $this->output->fail('No databases configured. Run: horde-components database configure'); + return 1; + } + + // Get database type from option or prompt + $type = $this->getDatabaseType($options, $configured); + + // SQLite doesn't need user configuration + if ($type === 'sqlite') { + $this->output->info('SQLite does not require user configuration (uses file permissions)'); + return 0; + } + + // Show security warning + $this->showSecurityWarning(); + + // Show privilege level descriptions + $this->showPrivilegeLevelInfo(); + + // Get privilege level from option or prompt + $level = $this->getPrivilegeLevel($options, $type); + + // Configure the selected privilege level + return $this->configureUser($type, $level, $options); + } + + /** + * Get database type from options or prompt. + * + * @param array $options CLI options + * @param array $configured List of configured databases + * @return string Database type + */ + private function getDatabaseType(array $options, array $configured): string + { + // Check if --db-type option provided + if (!empty($options['db_type'])) { + $type = strtolower($options['db_type']); + + // Validate type + if (!in_array($type, ['sqlite', 'mysql', 'postgresql'])) { + $this->output->fail("Invalid database type: {$type}"); + $this->output->info('Valid types: sqlite, mysql, postgresql'); + exit(1); + } + + // Check if configured + if (!in_array($type, $configured)) { + $this->output->fail("{$type} database not configured. Run: horde-components database configure --db-type={$type}"); + exit(1); + } + + $this->output->info("Using database type from --db-type: {$type}"); + $this->output->plain(''); + return $type; + } + + // Prompt for database type + return $this->promptDatabaseType($configured); + } + + /** + * Get privilege level from options or prompt. + * + * @param array $options CLI options + * @param string $type Database type + * @return string Privilege level + */ + private function getPrivilegeLevel(array $options, string $type): string + { + // Check if --user-type option provided + if (!empty($options['user_type'])) { + $level = strtolower($options['user_type']); + + // Validate level + if (!in_array($level, self::PRIVILEGE_LEVELS)) { + $this->output->fail("Invalid user type: {$level}"); + $this->output->info('Valid types: ' . implode(', ', self::PRIVILEGE_LEVELS)); + exit(1); + } + + $this->output->info("Using privilege level from --user-type: {$level}"); + $this->output->plain(''); + return $level; + } + + // Prompt for privilege level + return $this->promptPrivilegeLevel($type); + } + + /** + * Get list of configured database types. + * + * @return array List of configured database types + */ + private function getConfiguredDatabases(): array + { + $configured = []; + $types = ['sqlite', 'mysql', 'postgresql']; + + foreach ($types as $type) { + if ($this->config->hasSetting("database.{$type}.name")) { + $configured[] = $type; + } + } + + return $configured; + } + + /** + * Prompt for database type to configure. + * + * @param array $configured List of configured database types + * @return string Selected database type + */ + private function promptDatabaseType(array $configured): string + { + // If only one database configured, use it + if (count($configured) === 1) { + $type = $configured[0]; + $this->output->info("Using configured database: {$type}"); + $this->output->plain(''); + return $type; + } + + // Multiple databases - prompt user + $this->output->info('Multiple databases configured: ' . implode(', ', $configured)); + $this->output->plain(''); + + $defaultIndex = 0; + $choice = $this->cli->prompt( + 'Which database type to configure users for? [' . implode('/', $configured) . ']:', + $configured, + $defaultIndex + ); + + return $configured[$choice]; + } + + /** + * Show security warning about password storage. + */ + private function showSecurityWarning(): void + { + $this->output->warn('Passwords will be stored in plain text in config file'); + $this->output->info('File permissions: 0600 (readable only by you)'); + $this->output->info('Location: ~/.config/horde/components.php'); + $this->output->plain(''); + } + + /** + * Show privilege level information. + */ + private function showPrivilegeLevelInfo(): void + { + $this->output->bold('Available Privilege Levels:'); + $this->output->plain(''); + + foreach (self::PRIVILEGE_LEVELS as $level) { + $description = self::PRIVILEGE_DESCRIPTIONS[$level]; + $this->output->info(" {$level}: {$description}"); + } + + $this->output->plain(''); + $this->output->info('You can configure multiple levels (e.g., admin for setup, restricted for tests)'); + $this->output->info('Or configure just one level for all operations'); + $this->output->plain(''); + } + + /** + * Prompt for privilege level to configure. + * + * @param string $type Database type + * @return string Selected privilege level + */ + private function promptPrivilegeLevel(string $type): string + { + // Find default based on what's already configured + $defaultIndex = 0; + + // Prefer admin if nothing configured, otherwise select first configured + if ($this->config->hasSetting("database.{$type}.user.admin.username")) { + $defaultIndex = 0; + } elseif ($this->config->hasSetting("database.{$type}.user.power.username")) { + $defaultIndex = 1; + } elseif ($this->config->hasSetting("database.{$type}.user.restricted.username")) { + $defaultIndex = 2; + } + + $choice = $this->cli->prompt( + 'Which privilege level to configure? [admin/power/restricted]:', + self::PRIVILEGE_LEVELS, + $defaultIndex + ); + + return self::PRIVILEGE_LEVELS[$choice]; + } + + /** + * Configure user for specific privilege level. + * + * @param string $type Database type + * @param string $level Privilege level (admin, power, restricted) + * @param array $options CLI options + * @return int Exit code + */ + private function configureUser(string $type, string $level, array $options): int + { + $this->output->info("Configuring {$level} user for {$type}..."); + $this->output->plain(''); + + // Get database name for username suggestion + $dbName = $this->config->getSetting("database.{$type}.name"); + + // Suggest username based on database name and level + $suggestedUsername = $this->suggestUsername($dbName, $level); + + $currentUsername = $this->config->hasSetting("database.{$type}.user.{$level}.username") + ? $this->config->getSetting("database.{$type}.user.{$level}.username") + : $suggestedUsername; + + $username = $this->cli->prompt('Username:', null, $currentUsername); + $username = trim($username); + + if (empty($username)) { + $this->output->fail('Username cannot be empty'); + return 1; + } + + // Handle password based on --auto-password option + $password = $this->getPassword($type, $level, $options); + + if ($password === null) { + return 1; // Error already reported + } + + // Save to config + $this->config->setSetting("database.{$type}.user.{$level}.username", $username); + $this->config->setSetting("database.{$type}.user.{$level}.password", $password); + $this->config->writeToDisk(); + + $this->output->plain(''); + $this->output->ok("User '{$username}' configured for {$level} privilege level on {$type}"); + + // Show SQL commands for creating user + $this->showUserCreationSQL($type, $username, $password, $level, $dbName); + + // Ask if user wants to configure another level (unless --auto-password, which is batch mode) + if (!empty($options['auto_password'])) { + return 0; // Skip prompt in batch mode + } + + return $this->promptConfigureAnother($type); + } + + /** + * Get password from options or prompt. + * + * @param string $type Database type + * @param string $level Privilege level + * @param array $options CLI options + * @return string|null Password or null on error + */ + private function getPassword(string $type, string $level, array $options): ?string + { + // Check if --auto-password flag set + if (!empty($options['auto_password'])) { + $password = $this->generatePassword(); + $this->output->ok("Generated password: {$password}"); + $this->output->warn('Save this password - it will be stored in config file'); + return $password; + } + + // Prompt for password + $this->output->plain(''); + + // Check if there's a current password + $hasCurrentPassword = $this->config->hasSetting("database.{$type}.user.{$level}.password"); + + if ($hasCurrentPassword) { + $this->output->info('Password already configured. Leave empty to keep current, or enter new:'); + } else { + $this->output->info('Password (leave empty to generate random secure password):'); + } + + $password = $this->cli->passwordPrompt('Password:'); + $password = trim($password); + + // If empty and there's a current password, keep it + if (empty($password) && $hasCurrentPassword) { + $password = $this->config->getSetting("database.{$type}.user.{$level}.password"); + $this->output->ok('Keeping existing password'); + } elseif (empty($password)) { + // Generate random password if empty and no current password + $password = $this->generatePassword(); + $this->output->ok("Generated password: {$password}"); + $this->output->warn('Save this password - it will be stored in config file'); + } else { + // User entered a new password + $this->output->ok('Password updated'); + } + + return $password; + } + + /** + * Suggest username based on database name and privilege level. + * + * @param string $dbName Database name + * @param string $level Privilege level + * @return string Suggested username + */ + private function suggestUsername(string $dbName, string $level): string + { + // Remove common prefixes/suffixes + $base = preg_replace('/(^(horde|test)_|_(db|ci|test)$)/i', '', $dbName); + + // Shorten level names + $levelShort = match ($level) { + 'admin' => 'adm', + 'power' => 'pwr', + 'restricted' => 'rw', + default => $level, + }; + + return strtolower($base . '_' . $levelShort); + } + + /** + * Generate random secure password. + * + * @return string Generated password + */ + private function generatePassword(): string + { + // Use cryptographically secure random + $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*-_=+'; + $length = 24; + + $password = ''; + $max = strlen($chars) - 1; + + for ($i = 0; $i < $length; $i++) { + $password .= $chars[random_int(0, $max)]; + } + + return $password; + } + + /** + * Show SQL commands for creating user with appropriate privileges. + * + * @param string $type Database type + * @param string $username Username + * @param string $password Password + * @param string $level Privilege level + * @param string $dbName Database name + */ + private function showUserCreationSQL( + string $type, + string $username, + string $password, + string $level, + string $dbName + ): void { + $this->output->plain(''); + $this->output->bold('User Creation SQL:'); + $this->output->plain(''); + + if ($type === 'mysql') { + $this->showMySQLUserSQL($username, $password, $level, $dbName); + } elseif ($type === 'postgresql') { + $this->showPostgreSQLUserSQL($username, $password, $level, $dbName); + } + + $this->output->plain(''); + $this->output->info('These commands will be automatically included in install script:'); + $this->output->info(' horde-components database install'); + } + + /** + * Show MySQL user creation SQL. + * + * @param string $username Username + * @param string $password Password + * @param string $level Privilege level + * @param string $dbName Database name + */ + private function showMySQLUserSQL( + string $username, + string $password, + string $level, + string $dbName + ): void { + $privileges = match ($level) { + 'admin' => 'ALL PRIVILEGES', + 'power' => 'SELECT, INSERT, UPDATE, DELETE, CREATE, DROP, INDEX, ALTER', + 'restricted' => 'SELECT, INSERT, UPDATE, DELETE', + default => 'SELECT', + }; + + $this->output->plain("CREATE USER IF NOT EXISTS '{$username}'@'localhost' IDENTIFIED BY '{$password}';"); + $this->output->plain("GRANT {$privileges} ON `{$dbName}`.* TO '{$username}'@'localhost';"); + $this->output->plain("FLUSH PRIVILEGES;"); + } + + /** + * Show PostgreSQL user creation SQL. + * + * @param string $username Username + * @param string $password Password + * @param string $level Privilege level + * @param string $dbName Database name + */ + private function showPostgreSQLUserSQL( + string $username, + string $password, + string $level, + string $dbName + ): void { + // PostgreSQL uses roles + $createOptions = match ($level) { + 'admin' => 'CREATEDB CREATEROLE', + 'power' => 'NOCREATEDB NOCREATEROLE', + 'restricted' => 'NOCREATEDB NOCREATEROLE', + default => 'NOCREATEDB NOCREATEROLE', + }; + + $this->output->plain("CREATE USER {$username} WITH PASSWORD '{$password}' {$createOptions};"); + + if ($level === 'admin') { + $this->output->plain("GRANT ALL PRIVILEGES ON DATABASE {$dbName} TO {$username};"); + } elseif ($level === 'power') { + $this->output->plain("GRANT CONNECT ON DATABASE {$dbName} TO {$username};"); + $this->output->plain("GRANT CREATE ON SCHEMA public TO {$username};"); + $this->output->plain("GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO {$username};"); + } else { + $this->output->plain("GRANT CONNECT ON DATABASE {$dbName} TO {$username};"); + $this->output->plain("GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO {$username};"); + } + } + + /** + * Prompt to configure another privilege level. + * + * @param string $type Database type + * @return int Exit code (0 to continue, 1 to exit) + */ + private function promptConfigureAnother(string $type): int + { + $this->output->plain(''); + + $choices = ['y', 'n']; + $response = $this->cli->prompt( + 'Configure another privilege level? [y/n]:', + $choices, + 1 // Default to 'n' + ); + + // Response is numeric index + if ($choices[$response] === 'y') { + $this->output->plain(''); + // Reconfigure for the same database type + $level = $this->promptPrivilegeLevel($type); + return $this->configureUser($type, $level, []); + } + + $this->output->plain(''); + $this->output->ok('User configuration complete'); + $this->output->plain(''); + $this->output->info('Next steps:'); + $this->output->info(' 1. Install database and create users: horde-components database install'); + $this->output->info(' 2. Or manually create users using SQL commands above'); + + return 0; + } +} diff --git a/src/Runner/DatabaseRunner.php b/src/Runner/DatabaseRunner.php new file mode 100644 index 00000000..43defa61 --- /dev/null +++ b/src/Runner/DatabaseRunner.php @@ -0,0 +1,173 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Runner; + +use Horde\Components\Output; +use Horde\Components\ConfigProvider\PhpConfigFileProvider; +use Horde\Components\Runner\Database\ConfigureRunner; +use Horde\Components\Runner\Database\UserRunner; +use Horde\Components\Runner\Database\DropRunner; +use Horde\Components\Runner\Database\DbInstallRunner; +use Horde_Cli; + +/** + * Database Runner - Orchestrates database management subcommands + * + * Routes to specific subcommand runners based on user input. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class DatabaseRunner +{ + /** + * Constructor. + * + * @param Horde_Cli $cli CLI interaction handler + * @param Output $output Output handler + * @param PhpConfigFileProvider $config Configuration provider + */ + public function __construct( + private readonly Horde_Cli $cli, + private readonly Output $output, + private readonly PhpConfigFileProvider $config + ) {} + + /** + * Run database management command. + * + * Routes to appropriate subcommand runner based on arguments. + * + * @param array $arguments Command line arguments + * @param array $options CLI options + * @return int Exit code (0 = success, 1 = error) + */ + public function run(array $arguments, array $options = []): int + { + // Arguments: ['database', 'subcommand', ...] + $subcommand = $arguments[1] ?? null; + + return match ($subcommand) { + 'install' => $this->runInstall($options), + 'configure' => $this->runConfigure($options), + 'user' => $this->runUser($options), + 'drop' => $this->runDrop($options), + null => $this->showHelp(), + default => $this->unknownSubcommand($subcommand), + }; + } + + /** + * Run install subcommand. + * + * @param array $options CLI options + * @return int Exit code + */ + private function runInstall(array $options): int + { + $runner = new DbInstallRunner($this->cli, $this->output, $this->config); + return $runner->run($options); + } + + /** + * Run configure subcommand. + * + * @param array $options CLI options + * @return int Exit code + */ + private function runConfigure(array $options): int + { + $runner = new ConfigureRunner($this->cli, $this->output, $this->config); + return $runner->run($options); + } + + /** + * Run user subcommand. + * + * @param array $options CLI options + * @return int Exit code + */ + private function runUser(array $options): int + { + $runner = new UserRunner($this->cli, $this->output, $this->config); + return $runner->run($options); + } + + /** + * Run drop subcommand. + * + * @param array $options CLI options + * @return int Exit code + */ + private function runDrop(array $options): int + { + $runner = new DropRunner($this->cli, $this->output, $this->config); + return $runner->run($options); + } + + /** + * Show help text. + * + * @return int Exit code + */ + private function showHelp(): int + { + $this->output->bold('Database Module - Manage database lifecycle'); + $this->output->plain(''); + $this->output->plain('USAGE:'); + $this->output->plain(' horde-components database '); + $this->output->plain(' horde-components db '); + $this->output->plain(''); + $this->output->plain('SUBCOMMANDS:'); + $this->output->plain(' install Install database software and PHP drivers'); + $this->output->plain(' configure Configure database connection parameters'); + $this->output->plain(' user Configure database user credentials'); + $this->output->plain(' drop Drop all tables matching configured prefix'); + $this->output->plain(''); + $this->output->plain('EXAMPLES:'); + $this->output->plain(' horde-components database configure'); + $this->output->plain(' horde-components database user'); + $this->output->plain(' horde-components database install'); + $this->output->plain(' horde-components database drop'); + $this->output->plain(''); + $this->output->plain('For detailed help, run:'); + $this->output->plain(' horde-components help database'); + + return 0; + } + + /** + * Handle unknown subcommand. + * + * @param string $subcommand The unknown subcommand + * @return int Exit code + */ + private function unknownSubcommand(string $subcommand): int + { + $this->output->warn("Unknown subcommand: {$subcommand}"); + $this->output->plain(''); + $this->output->plain('Valid subcommands: install, configure, user, drop'); + $this->output->plain(''); + $this->output->plain('Run for help: horde-components database'); + + return 1; + } +} diff --git a/src/Runner/Fetchdocs.php b/src/Runner/Fetchdocs.php index c0bdc810..f19e368d 100644 --- a/src/Runner/Fetchdocs.php +++ b/src/Runner/Fetchdocs.php @@ -19,6 +19,7 @@ use Horde\Components\Exception; use Horde\Components\Helper\DocsOrigin as HelperDocsOrigin; use Horde\Components\Output; +use Horde_Http_Client; /** * Components_Runner_Fetchdocs:: fetches documentation for a component. @@ -41,13 +42,13 @@ class Fetchdocs * @param Component $component The component * @param array $options CLI options * @param Output $output The output handler - * @param \Horde_Http_Client $client A HTTP client + * @param Horde_Http_Client $client A HTTP client */ public function __construct( private readonly Component $component, private readonly array $options, private readonly Output $output, - private readonly \Horde_Http_Client $client + private readonly Horde_Http_Client $client ) {} public function run(): void diff --git a/src/Runner/Git.php b/src/Runner/Git.php index 162d8ba9..e7c930d6 100644 --- a/src/Runner/Git.php +++ b/src/Runner/Git.php @@ -59,18 +59,36 @@ public function __construct( private readonly Output $output, private readonly GitHelper $gitHelper ) { - $this->gitRepoBase = $this->config->hasSetting('git_repo_base') - ? $this->config->getSetting('git_repo_base') - : 'https://github.com/horde/'; + // Try new name first, then fall back to old name for backwards compatibility + $this->gitRepoBase = $this->config->hasSetting('scm.repo.base') + ? $this->config->getSetting('scm.repo.base') + : ($this->config->hasSetting('git_repo_base') + ? $this->config->getSetting('git_repo_base') + : 'https://github.com/horde/'); $checkoutDir = $this->config->hasSetting('checkout.dir') ? $this->config->getSetting('checkout.dir') - : '/srv/git/horde'; + : '/srv/git'; // Normalize path: remove trailing slash to avoid double slashes when concatenating $this->localCheckoutDir = rtrim($checkoutDir, '/'); } + /** + * Normalize component name to include vendor prefix + * + * @param string $component Component name (e.g., "bundle" or "horde/bundle") + * @return string Component with vendor prefix (e.g., "horde/bundle") + */ + private function normalizeComponentName(string $component): string + { + // Add vendor prefix if not present (assume horde) + if (!str_contains($component, '/')) { + return 'horde/' . $component; + } + return $component; + } + public function run(): void { if (count($this->arguments) == 1) { @@ -87,10 +105,12 @@ public function run(): void $this->output->help('Cloning all components has not yet been ported from git-tools'); return; } - $component = $this->arguments[2]; + $component = $this->normalizeComponentName($this->arguments[2]); $branch = $this->arguments[4] ?? ''; $componentDir = $this->localCheckoutDir . '/' . $component . '/'; - $cloneUrl = $this->gitRepoBase . '/' . $component . '.git'; + // Extract component name for clone URL (without vendor prefix) + $componentName = basename($component); + $cloneUrl = $this->gitRepoBase . '/' . $componentName . '.git'; // Achieved fixed format, delegate to helper $this->gitHelper->workflowClone( $this->output, @@ -106,6 +126,7 @@ public function run(): void $this->output->help('checkout component branch'); } [$git, $action, $component, $branch] = $this->arguments; + $component = $this->normalizeComponentName($component); $componentDir = $this->localCheckoutDir . '/' . $component . '/'; $this->gitHelper->workflowCheckout( $this->output, @@ -121,6 +142,7 @@ public function run(): void return; } [$git, $action, $component] = $this->arguments; + $component = $this->normalizeComponentName($component); $componentDir = $this->localCheckoutDir . '/' . $component . '/'; $this->gitHelper->fetch($componentDir); return; @@ -132,6 +154,7 @@ public function run(): void return; } [$git, $action, $component, $branch, $source] = $this->arguments; + $component = $this->normalizeComponentName($component); $componentDir = $this->localCheckoutDir . '/' . $component . '/'; $this->gitHelper->workflowBranch( $this->output, @@ -147,6 +170,7 @@ public function run(): void $this->output->help('tag component branch tagname comment'); } [$git, $action, $component, $branch, $tag, $comment] = $this->arguments; + $component = $this->normalizeComponentName($component); $componentDir = $this->localCheckoutDir . '/' . $component . '/'; if (!$this->gitHelper->localBranchExists($componentDir, $branch)) { $this->output->warn("Cannot tag, local branch does not exist"); @@ -164,6 +188,7 @@ public function run(): void exit(); } [$git, $action, $component] = $this->arguments; + $component = $this->normalizeComponentName($component); $componentDir = $this->localCheckoutDir . '/' . $component . '/'; $this->gitHelper->push($componentDir); return; diff --git a/src/Runner/Github.php b/src/Runner/Github.php index ab1ba62c..cfa0ea47 100644 --- a/src/Runner/Github.php +++ b/src/Runner/Github.php @@ -67,9 +67,12 @@ public function __construct( private readonly GithubApiClient $client, private readonly GitCheckoutDirectory $gitCheckoutDirectory ) { - $this->gitRepoBase = $this->config->hasSetting('git_repo_base') - ? $this->config->getSetting('git_repo_base') - : 'https://github.com/horde/'; + // Try new name first, then fall back to old name for backwards compatibility + $this->gitRepoBase = $this->config->hasSetting('scm.repo.base') + ? $this->config->getSetting('scm.repo.base') + : ($this->config->hasSetting('git_repo_base') + ? $this->config->getSetting('git_repo_base') + : 'https://github.com/horde/'); $this->localCheckoutDir = $this->config->hasSetting('checkout.dir') ? $this->config->getSetting('checkout.dir') diff --git a/src/Runner/Init.php b/src/Runner/Init.php index a4e09374..290e615c 100644 --- a/src/Runner/Init.php +++ b/src/Runner/Init.php @@ -1,4 +1,5 @@ arguments[1]; if (!in_array($type, ['application', 'library', 'theme'])) { throw new Exception( - "Invalid component type: {$type}\n" . - "Must be one of: application, library, theme\n\n" . - "Run 'horde-components init' (no arguments) for interactive mode." + "Invalid component type: {$type}\n" + . "Must be one of: application, library, theme\n\n" + . "Run 'horde-components init' (no arguments) for interactive mode." ); } @@ -210,8 +211,8 @@ private function validateConfig(array $config): void if (!empty($errors)) { throw new Exception( - "Configuration errors:\n - " . implode("\n - ", $errors) . "\n\n" . - "Run 'horde-components init' (no arguments) for usage information." + "Configuration errors:\n - " . implode("\n - ", $errors) . "\n\n" + . "Run 'horde-components init' (no arguments) for usage information." ); } } diff --git a/src/Runner/InstallRunner.php b/src/Runner/InstallRunner.php index 5a82f430..62207b9f 100644 --- a/src/Runner/InstallRunner.php +++ b/src/Runner/InstallRunner.php @@ -11,6 +11,7 @@ use Horde\Components\Wrapper\HordeYml; use Horde\Composer\RecursiveCopy; use stdClass; +use Exception; class InstallRunner { @@ -84,7 +85,7 @@ public function run() // Inject all horde apps as local sources. try { $composerJson = $this->installationDirectory->getComposerJson(); - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->fail('Could not read composer.json file from installation directory: ' . $this->installationDirectory); return; } diff --git a/src/Runner/Pullrequest.php b/src/Runner/Pullrequest.php index 85aba87f..1a895d66 100644 --- a/src/Runner/Pullrequest.php +++ b/src/Runner/Pullrequest.php @@ -22,6 +22,7 @@ use Horde\Components\Helper\GitHubChecker; use Horde\Components\Helper\Git as GitHelper; use Horde\Components\Helper\PullRequestManager; +use Exception; /** * Components_Runner_Pullrequest:: runner for pull request operations. @@ -305,7 +306,7 @@ private function handleCheckout(?string $prNumber): void try { $apiClient = $this->prManager->getApiClient(); $pr = $apiClient->getPullRequest($repo, $prNumber); - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to fetch PR #{$prNumber}: {$e->getMessage()}"); return; } @@ -363,7 +364,7 @@ private function checkoutSameRepoPR(string $localDir, \Horde\GithubApiClient\Git $result = $this->gitHelper->fetch($localDir); if ($result->getReturnValue() !== 0) { - throw new \Exception("Fetch failed: {$result->getOutputString()}"); + throw new Exception("Fetch failed: {$result->getOutputString()}"); } // Check if local branch exists @@ -375,7 +376,7 @@ private function checkoutSameRepoPR(string $localDir, \Horde\GithubApiClient\Git $result = $this->gitHelper->checkoutBranch($localDir, $branchName); if ($result->getReturnValue() !== 0) { - throw new \Exception("Checkout failed: {$result->getOutputString()}"); + throw new Exception("Checkout failed: {$result->getOutputString()}"); } // Update from remote (pull) @@ -387,19 +388,19 @@ private function checkoutSameRepoPR(string $localDir, \Horde\GithubApiClient\Git $result = $this->gitHelper->createRemoteTrackingBranch($localDir, $branchName, 'origin'); if ($result->getReturnValue() !== 0) { - throw new \Exception("Failed to create tracking branch: {$result->getOutputString()}"); + throw new Exception("Failed to create tracking branch: {$result->getOutputString()}"); } // Checkout the newly created branch $result = $this->gitHelper->checkoutBranch($localDir, $branchName); if ($result->getReturnValue() !== 0) { - throw new \Exception("Checkout failed: {$result->getOutputString()}"); + throw new Exception("Checkout failed: {$result->getOutputString()}"); } } $this->output->ok("Successfully checked out PR #{$pr->number} on branch '{$branchName}'"); - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to checkout branch: {$e->getMessage()}"); } } @@ -435,7 +436,7 @@ private function checkoutCrossRepoPR(string $localDir, \Horde\GithubApiClient\Gi $result = $this->gitHelper->fetch($localDir); if ($result->getReturnValue() !== 0) { - throw new \Exception("Fetch failed: {$result->getOutputString()}"); + throw new Exception("Fetch failed: {$result->getOutputString()}"); } // Check if local branch exists @@ -447,7 +448,7 @@ private function checkoutCrossRepoPR(string $localDir, \Horde\GithubApiClient\Gi $result = $this->gitHelper->checkoutBranch($localDir, $localBranchName); if ($result->getReturnValue() !== 0) { - throw new \Exception("Checkout failed: {$result->getOutputString()}"); + throw new Exception("Checkout failed: {$result->getOutputString()}"); } $this->output->plain(" Pulling latest changes..."); @@ -462,7 +463,7 @@ private function checkoutCrossRepoPR(string $localDir, \Horde\GithubApiClient\Gi $result = $this->gitHelper->branchFromLocal($localDir, $localBranchName, "{$remoteName}/{$remoteBranch}"); if ($result->getReturnValue() !== 0) { - throw new \Exception("Failed to create branch: {$result->getOutputString()}"); + throw new Exception("Failed to create branch: {$result->getOutputString()}"); } } @@ -470,13 +471,13 @@ private function checkoutCrossRepoPR(string $localDir, \Horde\GithubApiClient\Gi $result = $this->gitHelper->checkoutBranch($localDir, $localBranchName); if ($result->getReturnValue() !== 0) { - throw new \Exception("Checkout failed: {$result->getOutputString()}"); + throw new Exception("Checkout failed: {$result->getOutputString()}"); } } $this->output->ok("Successfully checked out PR #{$pr->number} on branch '{$localBranchName}'"); $this->output->plain(" Tracking: {$remoteName}/{$remoteBranch}"); - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to checkout cross-repo PR: {$e->getMessage()}"); } } @@ -500,7 +501,7 @@ private function addRemote(string $localDir, string $name, string $url): void chdir($oldDir); if ($returnCode !== 0) { - throw new \Exception("Failed to add remote: " . implode("\n", $output)); + throw new Exception("Failed to add remote: " . implode("\n", $output)); } } @@ -556,7 +557,7 @@ private function deducePRFromCurrentBranch(): ?string $this->output->warn("No open PR found for branch '{$currentBranch}'"); $this->output->help('Specify PR number explicitly: horde-components pr merge '); return null; - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to deduce PR from branch: {$e->getMessage()}"); return null; } @@ -604,7 +605,7 @@ private function handleApprove(?string $prNumber): void // Optional: prompt for review comment // For now, approve without comment $this->prManager->approvePullRequest($prNumber); - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to approve PR #{$prNumber}: {$e->getMessage()}"); } } @@ -667,7 +668,7 @@ private function handleBlock(?string $prNumber): void } $this->prManager->requestChangesPullRequest($prNumber, $comment); - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to request changes on PR #{$prNumber}: {$e->getMessage()}"); } } @@ -714,7 +715,7 @@ private function handleMerge(?string $prNumber): void // Attempt to merge $this->prManager->mergePullRequest($prNumber); - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to merge PR #{$prNumber}: {$e->getMessage()}"); } } @@ -760,7 +761,7 @@ private function handleClose(?string $prNumber): void // Attempt to close $this->prManager->closePullRequest($prNumber); - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to close PR #{$prNumber}: {$e->getMessage()}"); } } @@ -807,7 +808,7 @@ private function handleReopen(?string $prNumber): void // Attempt to reopen $this->prManager->reopenPullRequest($prNumber); - } catch (\Exception $e) { + } catch (Exception $e) { $this->output->error("Failed to reopen PR #{$prNumber}: {$e->getMessage()}"); } } diff --git a/src/Runner/Release.php b/src/Runner/Release.php index ee4fe021..d59ff604 100644 --- a/src/Runner/Release.php +++ b/src/Runner/Release.php @@ -23,6 +23,7 @@ use Horde\Components\Component\ComponentDirectory; use Horde\Components\Helper\Git as GitHelper; use Horde\Components\Helper\Composer as ComposerHelper; +use Horde\Components\Helper\Shell; use Horde\Components\Release\HordeRelease; use Horde\GithubApiClient\GithubApiConfig; @@ -89,6 +90,7 @@ public function run(): void $path = new ComponentDirectory($this->component->getComponentDirectory()); $gitHelper = new GitHelper(); $composerHelper = new ComposerHelper(); + $shell = new Shell(); // Get GitHubChecker and GitHubReleaseCreator from dependencies $githubChecker = new \Horde\Components\Helper\GitHubChecker($gitHelper); @@ -108,9 +110,9 @@ public function run(): void $gitHelper, $path, $this->output, - $githubChecker, $githubReleaseCreator, - $this->qcTasks + $this->qcTasks, + $shell ); $release->run($this->component, $this->options); diff --git a/src/Runner/Status.php b/src/Runner/Status.php index 174ad4aa..a2c53aca 100644 --- a/src/Runner/Status.php +++ b/src/Runner/Status.php @@ -26,6 +26,7 @@ use Horde\Components\Auth\GitHubAppAuthenticationService; use Horde\GithubApiClient\GithubApiClient; use Horde\GithubApiClient\GithubApiConfig; +use Throwable; /** * Horde\Components\Runner\Status:: runner for status output. @@ -67,9 +68,12 @@ public function __construct( private readonly InstallationDirectory $installDir, private readonly AuthenticationFactory $authFactory, ) { - $this->gitRepoBase = $this->config->hasSetting('git_repo_base') - ? $this->config->getSetting('git_repo_base') - : 'https://github.com/horde/'; + // Try new name first, then fall back to old name for backwards compatibility + $this->gitRepoBase = $this->config->hasSetting('scm.repo.base') + ? $this->config->getSetting('scm.repo.base') + : ($this->config->hasSetting('git_repo_base') + ? $this->config->getSetting('git_repo_base') + : 'https://github.com/horde/'); } public function run(): void @@ -178,7 +182,7 @@ private function checkGitHubAuthentication(): void } catch (\Exception $e) { $this->output->warn("Authentication failed: " . $e->getMessage()); } - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->output->warn("Error checking GitHub authentication: " . $e->getMessage()); } } @@ -279,7 +283,7 @@ private function checkTokenScopes(GithubApiClient $apiClient): void if ($scopes->canReadOrganizations()) { $this->output->ok("Token can read organizations"); } - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->output->warn("Failed to verify token scopes: " . $e->getMessage()); if (str_contains($e->getMessage(), '401')) { $this->output->warn("Token appears to be invalid, expired, or revoked"); @@ -328,7 +332,7 @@ private function checkRateLimit(GithubApiClient $apiClient): void } else { $this->output->info("Rate limit reset time: $resetTime"); } - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->output->warn("Failed to check rate limit: " . $e->getMessage()); } } @@ -396,7 +400,7 @@ private function runGitHubAppVerify(): void $this->showTroubleshootingHelp($e); } - } catch (\Throwable $e) { + } catch (Throwable $e) { $this->output->warn("Error during verification: " . $e->getMessage()); } } diff --git a/src/Runner/Update.php b/src/Runner/Update.php index 45fca5c2..96a5f774 100644 --- a/src/Runner/Update.php +++ b/src/Runner/Update.php @@ -23,6 +23,7 @@ use Horde\Components\Output; use Horde\Components\Release\Notes as ReleaseNotes; use Horde\Components\Component\Source; +use Horde_Pear_Exception; /** * Components_Runner_Update updates the package files of a Horde component. @@ -52,7 +53,7 @@ public function __construct( /** * @throws Exception - * @throws \Horde_Pear_Exception + * @throws Horde_Pear_Exception */ public function run(): void { diff --git a/src/Runner/WebsiteConfig.php b/src/Runner/WebsiteConfig.php index 76f60049..e7370dc8 100644 --- a/src/Runner/WebsiteConfig.php +++ b/src/Runner/WebsiteConfig.php @@ -70,34 +70,57 @@ public static function fromConfigProvider( ?string $fallbackToken = null ): self { // Get values with defaults relative to components root - $inputDir = $config->hasSetting('web_input') - ? $config->getSetting('web_input') - : $componentsRoot . '/data/webhooks'; + // Try new names first, then fall back to old names for backwards compatibility + $inputDir = $config->hasSetting('devsite.input_dir') + ? $config->getSetting('devsite.input_dir') + : ($config->hasSetting('web_input') + ? $config->getSetting('web_input') + : $componentsRoot . '/data/webhooks'); - $outputDir = $config->hasSetting('web_output') - ? $config->getSetting('web_output') - : $componentsRoot . '/build/dev.horde.org'; + $outputDir = $config->hasSetting('devsite.output_dir') + ? $config->getSetting('devsite.output_dir') + : ($config->hasSetting('web_output') + ? $config->getSetting('web_output') + : $componentsRoot . '/build/dev.horde.org'); - $templatesDir = $config->hasSetting('web_templates') - ? $config->getSetting('web_templates') - : $componentsRoot . '/data/website'; + $templatesDir = $config->hasSetting('devsite.template_dir') + ? $config->getSetting('devsite.template_dir') + : ($config->hasSetting('web_templates') + ? $config->getSetting('web_templates') + : $componentsRoot . '/data/website'); - $componentsFile = $config->hasSetting('web_components') - ? $config->getSetting('web_components') - : $templatesDir . '/components.json'; + $componentsFile = $config->hasSetting('devsite.components') + ? $config->getSetting('devsite.components') + : ($config->hasSetting('web_components') + ? $config->getSetting('web_components') + : $templatesDir . '/components.json'); - $organization = $config->hasSetting('web_org') - ? $config->getSetting('web_org') - : 'horde'; + // For organization, prefer repo.org over devsite.org over legacy web_org + $organization = $config->hasSetting('repo.org') + ? $config->getSetting('repo.org') + : ($config->hasSetting('devsite.org') + ? $config->getSetting('devsite.org') + : ($config->hasSetting('web_org') + ? $config->getSetting('web_org') + : 'horde')); - $gitDir = $config->hasSetting('web_git_dir') - ? $config->getSetting('web_git_dir') - : null; + // For git directory, prefer checkout.dir over devsite.git_dir over legacy web_git_dir + $gitDir = $config->hasSetting('checkout.dir') + ? $config->getSetting('checkout.dir') + : ($config->hasSetting('devsite.git_dir') + ? $config->getSetting('devsite.git_dir') + : ($config->hasSetting('web_git_dir') + ? $config->getSetting('web_git_dir') + : null)); - // Token priority: web_token config > fallback token (from GithubApiConfig) - $token = $config->hasSetting('web_token') - ? $config->getSetting('web_token') - : $fallbackToken; + // For token, prefer github.token over devsite.token over legacy web_token + $token = $config->hasSetting('github.token') + ? $config->getSetting('github.token') + : ($config->hasSetting('devsite.token') + ? $config->getSetting('devsite.token') + : ($config->hasSetting('web_token') + ? $config->getSetting('web_token') + : $fallbackToken)); return new self( $inputDir, diff --git a/src/RuntimeContext/GitCheckoutDirectory.php b/src/RuntimeContext/GitCheckoutDirectory.php index 842b3ebc..a85d0c3a 100644 --- a/src/RuntimeContext/GitCheckoutDirectory.php +++ b/src/RuntimeContext/GitCheckoutDirectory.php @@ -10,7 +10,11 @@ use Horde\Components\Component\ComponentDirectory; /** - * Represents the supposed root directory of a flat git tree checkout + * Represents the supposed root directory of a git checkout tree + * + * The checkout directory contains vendor subdirectories (e.g., horde/) + * with components nested inside: checkout.dir/vendor/component/ + * Example: /home/user/git/horde/ActiveSync */ class GitCheckoutDirectory implements Stringable { @@ -28,8 +32,15 @@ public function __toString() public function getGitDir(string $component): ComponentDirectory { + // Component can be "horde/bundle" or just "bundle" + // If no vendor prefix, assume "horde" for backwards compatibility + if (!str_contains($component, '/')) { + $component = 'horde/' . $component; + } + foreach ($this->getGitDirs() as $gitDir) { - if (str_ends_with(mb_strtolower((string) $gitDir), $component)) { + // Match against full path ending with vendor/component + if (str_ends_with(mb_strtolower((string) $gitDir), mb_strtolower($component))) { return $gitDir; } } @@ -38,11 +49,11 @@ public function getGitDir(string $component): ComponentDirectory public function getGitDirs(): GitDirectoryIterator { - return $componentsCount = new GitDirectoryIterator($this->path . '/*/.git'); + return $componentsCount = new GitDirectoryIterator($this->path . '/*/*/.git'); } public function getHordeYmlDirs(): GitDirectoryIterator { - return $componentsCount = new GitDirectoryIterator($this->path . '/*/.horde.yml'); + return $componentsCount = new GitDirectoryIterator($this->path . '/*/*/.horde.yml'); } public function getComposerJsonPath(): string diff --git a/src/Scaffolding/ReplacementBuilder.php b/src/Scaffolding/ReplacementBuilder.php index d3e13468..f7108ba9 100644 --- a/src/Scaffolding/ReplacementBuilder.php +++ b/src/Scaffolding/ReplacementBuilder.php @@ -1,4 +1,5 @@ isDir()) { // Create directory if (!is_dir($targetItemPath)) { - mkdir($targetItemPath, 0755, true); + mkdir($targetItemPath, 0o755, true); $directoriesCreated++; } } else { @@ -141,7 +142,7 @@ private function processFile( // Ensure target directory exists $targetDir = dirname($targetPath); if (!is_dir($targetDir)) { - mkdir($targetDir, 0755, true); + mkdir($targetDir, 0o755, true); } // Check if file is binary @@ -212,8 +213,8 @@ private function shouldSkip(string $relativePath): bool ]; foreach ($skipPatterns as $pattern) { - if (str_starts_with($relativePath, $pattern) || - str_contains($relativePath, '/' . $pattern)) { + if (str_starts_with($relativePath, $pattern) + || str_contains($relativePath, '/' . $pattern)) { return true; } } diff --git a/src/Task/AbstractTask.php b/src/Task/AbstractTask.php new file mode 100644 index 00000000..466b7b8b --- /dev/null +++ b/src/Task/AbstractTask.php @@ -0,0 +1,123 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Task; + +use Horde\Components\Output; + +/** + * Base implementation for all tasks. + * + * Provides common functionality and sensible defaults. + * Tasks extending this class only need to implement getName() and run(). + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +abstract class AbstractTask implements TaskInterface +{ + /** + * Constructor. + * + * @param Output $output Output handler for messages + * @param bool $pretend Pretend (dry-run) mode - don't make changes + */ + public function __construct( + protected readonly Output $output, + protected readonly bool $pretend = false + ) {} + + /** + * Get task name for display. + * + * Subclasses should override to provide descriptive name. + * + * @return string Human-readable task name + */ + abstract public function getName(): string; + + /** + * Validate preconditions before execution. + * + * Default implementation has no validation errors. + * Override to add validation logic. + * + * @param Context $context Execution context + * @return array Empty if valid, error messages otherwise + */ + public function validate(Context $context): array + { + return []; + } + + /** + * Check if task should be skipped. + * + * Default implementation never skips. + * Override to add skip logic. + * + * @param Context $context Execution context + * @return bool True to skip, false to run + */ + public function shouldSkip(Context $context): bool + { + return false; + } + + /** + * Execute the task. + * + * Subclasses must implement the actual task logic. + * + * @param Context $context Execution context + * @return Result Result object with success/failure and details + */ + abstract public function run(Context $context): Result; + + /** + * Helper for pretend mode - outputs message and returns success result. + * + * @param string $action Action that would be performed + * @return Result Success result with pretend message + */ + protected function pretend(string $action): Result + { + $this->output->info("[PRETEND] Would: {$action}"); + return Result::success("Would: {$action}"); + } + + /** + * Get output handler. + * + * @return Output + */ + protected function getOutput(): Output + { + return $this->output; + } + + /** + * Check if in pretend mode. + * + * @return bool True if pretending + */ + protected function isPretending(): bool + { + return $this->pretend; + } +} diff --git a/src/Task/Build/BuildPharTask.php b/src/Task/Build/BuildPharTask.php new file mode 100644 index 00000000..69a566e3 --- /dev/null +++ b/src/Task/Build/BuildPharTask.php @@ -0,0 +1,152 @@ + + * @category Horde + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\Build; + +use Horde\Components\Helper\Shell as ShellHelper; +use Horde\Components\Output; +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Exception; + +/** + * Build a PHAR archive using Box. + * + * Builds PHAR with static filename (e.g., 'horde-components.phar') that + * overwrites existing local file. Skips if box.json.dist doesn't exist. + * + * Optional Options: + * - phar_name (string) - Override PHAR filename + * + * Emitted Facts: + * - phar.built (bool) - True if PHAR built + * - phar.file_path (string) - Absolute path to PHAR + * - phar.file_name (string) - PHAR filename + * - phar.file_size (int) - PHAR size in bytes + * + * @author Ralf Lang + * @category Horde + * @copyright 2024-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ +class BuildPharTask extends AbstractTask +{ + public function __construct( + Output $output, + private readonly ShellHelper $shellHelper, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + public function shouldSkip(Context $context): bool + { + // Skip if no box.json.dist (not a PHAR-enabled component) + $componentPath = $context->getComponentPath(); + return !file_exists($componentPath . '/box.json.dist'); + } + + + public function getName(): string + { + return "Buil\1 \2har"; + } + public function run(Context $context): Result + { + $componentPath = $context->getComponentPath(); + $boxConfig = $componentPath . '/box.json.dist'; + + // Validate box.json.dist exists + if (!file_exists($boxConfig)) { + throw new Exception('box.json.dist not found'); + } + + // Check box utility available + $result = $this->shellHelper->run('which box', $componentPath); + if ($result->getCode() !== 0) { + throw new Exception( + 'Box utility not found in PATH. Install with: composer global require humbug/box' + ); + } + + // Determine PHAR filename + $pharName = $context->getOption('phar_name'); + + if ($pharName === null) { + // Read from box.json.dist + $boxJson = json_decode(file_get_contents($boxConfig), true); + $pharName = basename($boxJson['output'] ?? 'dist.phar'); + } + + $pharPath = $componentPath . '/' . $pharName; + + // Build PHAR + if (!$this->pretend) { + $result = $this->shellHelper->run('box compile', $componentPath); + + if ($result->getCode() !== 0) { + throw new Exception('Failed to build PHAR: ' . $result->getOutput()); + } + + // Verify PHAR was created + if (!file_exists($pharPath)) { + throw new Exception("PHAR not found after build: {$pharPath}"); + } + + // Quick sanity check: PHAR is valid + $result = $this->shellHelper->run("php {$pharName} --version", $componentPath); + if ($result->getCode() !== 0) { + throw new Exception('Built PHAR is not valid/executable'); + } + } + + $pharSize = $this->pretend ? 0 : filesize($pharPath); + + // Emit facts + $context->setFact('phar.built', true); + $context->setFact('phar.file_path', $pharPath); + $context->setFact('phar.file_name', $pharName); + $context->setFact('phar.file_size', $pharSize); + + $action = $this->pretend ? 'Would build' : 'Built'; + $sizeInfo = $this->pretend ? '' : ' (' . $this->formatBytes($pharSize) . ')'; + + return Result::success( + "{$action} PHAR: {$pharName}{$sizeInfo}", + [ + 'phar_path' => $pharPath, + 'phar_name' => $pharName, + 'phar_size' => $pharSize, + ] + ); + } + + /** + * Format bytes to human-readable size. + */ + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + + $bytes /= (1 << (10 * $pow)); + + return round($bytes, 2) . ' ' . $units[$pow]; + } +} diff --git a/src/Task/Composer/ComposerValidateTask.php b/src/Task/Composer/ComposerValidateTask.php new file mode 100644 index 00000000..701867b2 --- /dev/null +++ b/src/Task/Composer/ComposerValidateTask.php @@ -0,0 +1,116 @@ + + * @category Horde + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\Composer; + +use Horde\Components\Helper\Shell as ShellHelper; +use Horde\Components\Output; +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Exception; + +/** + * Run composer validate on composer.json. + * + * Validates that composer.json is well-formed and contains required fields. + * + * Emitted Facts: + * - composer.validated (bool) - True if validation completed + * - composer.valid (bool) - True if composer.json is valid + * - composer.validation_errors (array) - List of validation errors (if any) + * + * @author Ralf Lang + * @category Horde + * @copyright 2024-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ +class ComposerValidateTask extends AbstractTask +{ + public function __construct( + Output $output, + private readonly ShellHelper $shellHelper, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + + public function getName(): string + { + return "Composer Validate"; + } + public function run(Context $context): Result + { + $componentPath = $context->getComponentPath(); + $composerJsonPath = $componentPath . '/composer.json'; + $composerLockPath = $componentPath . '/composer.lock'; + + // Check composer.json exists + if (!file_exists($composerJsonPath)) { + throw new Exception("composer.json not found: {$composerJsonPath}"); + } + + // Delete composer.lock if it exists (will be out of sync after updating composer.json) + if (file_exists($composerLockPath)) { + if (!$this->pretend) { + unlink($composerLockPath); + $this->output->info('Deleted composer.lock (will be regenerated on next composer install)'); + } else { + $this->output->info('Would delete composer.lock'); + } + } + + // Run composer validate + $result = $this->shellHelper->run( + 'composer validate --no-check-publish --no-check-all', + $componentPath + ); + + $isValid = $result->getReturnValue() === 0; + $errors = []; + + if (!$isValid) { + // Parse errors from output + $output = $result->getOutputString(); + $lines = explode("\n", $output); + + foreach ($lines as $line) { + if (str_contains($line, 'error') || str_contains($line, 'warning')) { + $errors[] = trim($line); + } + } + } + + // Emit facts + $context->setFact('composer.validated', true); + $context->setFact('composer.valid', $isValid); + $context->setFact('composer.validation_errors', $errors); + + if ($isValid) { + return Result::success('composer.json is valid', [ + 'valid' => true, + 'errors' => [], + ]); + } + + return Result::failure( + 'composer.json validation failed: ' . implode(', ', $errors), + count($errors) + ); + } +} diff --git a/src/Task/Context.php b/src/Task/Context.php new file mode 100644 index 00000000..5b45ba3b --- /dev/null +++ b/src/Task/Context.php @@ -0,0 +1,142 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Task; + +use Horde\Components\Component; + +/** + * Execution context passed through task pipeline. + * + * Provides access to the component being operated on, user options, + * and a fact store for inter-task communication. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class Context +{ + /** + * Facts emitted by tasks for use by later tasks. + * + * @var array + */ + private array $facts = []; + + /** + * Constructor. + * + * @param Component $component Component being operated on + * @param array $options User options/settings + */ + public function __construct( + public readonly Component $component, + public readonly array $options = [] + ) {} + + /** + * Store a fact for later tasks. + * + * Facts are transient values computed during pipeline execution + * (e.g., calculated version, commit SHA, etc.) that later tasks + * may depend on. + * + * @param string $key Fact name + * @param mixed $value Fact value + */ + public function setFact(string $key, mixed $value): void + { + $this->facts[$key] = $value; + } + + /** + * Retrieve a fact from earlier tasks. + * + * @param string $key Fact name + * @param mixed $default Default value if fact not set + * @return mixed Fact value or default + */ + public function getFact(string $key, mixed $default = null): mixed + { + return $this->facts[$key] ?? $default; + } + + /** + * Check if a fact exists. + * + * @param string $key Fact name + * @return bool True if fact exists + */ + public function hasFact(string $key): bool + { + return array_key_exists($key, $this->facts); + } + + /** + * Get all facts. + * + * @return array All facts + */ + public function getFacts(): array + { + return $this->facts; + } + + /** + * Get component directory path. + * + * Convenience method for tasks. + * + * @return string Component directory path + */ + public function getComponentPath(): string + { + return (string) $this->component->getComponentDirectory(); + } + + /** + * Get an option value. + * + * Checks facts first (for runtime-set options), then options array. + * + * @param string $key Option name + * @param mixed $default Default value if option not set + * @return mixed Option value or default + */ + public function getOption(string $key, mixed $default = null): mixed + { + $factKey = "_option.{$key}"; + if ($this->hasFact($factKey)) { + return $this->getFact($factKey); + } + return $this->options[$key] ?? $default; + } + + /** + * Set an option value at runtime. + * + * Stores in facts with _option prefix to avoid collision. + * + * @param string $key Option name + * @param mixed $value Option value + */ + public function setOption(string $key, mixed $value): void + { + $this->setFact("_option.{$key}", $value); + } +} diff --git a/src/Task/Git/CommitTask.php b/src/Task/Git/CommitTask.php new file mode 100644 index 00000000..b72bf219 --- /dev/null +++ b/src/Task/Git/CommitTask.php @@ -0,0 +1,172 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\Git; + +use Horde\Components\Output; +use Horde\Components\Helper\Git as GitHelper; +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Exception; + +/** + * Create a git commit. + * + * This task creates a git commit with the specified message. + * It supports pretend mode (dry-run) and validates that the + * directory is a git repository. + * + * Facts consumed: + * - None (self-contained) + * + * Facts emitted: + * - git.commit_sha: SHA of created commit (if successful) + * + * Options: + * - commit_message: Commit message (required) + * - files: Array of files to stage, or null to use already-staged (optional) + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class CommitTask extends AbstractTask +{ + /** + * Constructor. + * + * @param Output $output Output handler + * @param GitHelper $gitHelper Git operations helper + * @param bool $pretend Pretend (dry-run) mode + */ + public function __construct( + Output $output, + private readonly GitHelper $gitHelper, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + /** + * Get task name. + * + * @return string + */ + public function getName(): string + { + return 'Git Commit'; + } + + /** + * Validate preconditions. + * + * Checks: + * - Component path is a git repository + * - Commit message is provided + * - If files specified, they exist + * + * @param Context $context Execution context + * @return array Error messages (empty if valid) + */ + public function validate(Context $context): array + { + $errors = []; + + // Check git repository + $path = $context->getComponentPath(); + if (!is_dir($path . '/.git')) { + $errors[] = 'Not a git repository'; + } + + // Check commit message + $message = $context->getOption('commit_message') ?? ''; + if (empty($message)) { + $errors[] = 'Commit message is required (option: commit_message)'; + } + + // Check files if specified + $files = $context->getOption('files') ?? null; + if (is_array($files)) { + foreach ($files as $file) { + $fullPath = $path . '/' . ltrim($file, '/'); + if (!file_exists($fullPath)) { + $errors[] = "File does not exist: {$file}"; + } + } + } + + return $errors; + } + + /** + * Execute the task. + * + * Creates a git commit. If files are specified, stages them first. + * In pretend mode, shows what would be committed. + * + * @param Context $context Execution context + * @return Result + */ + public function run(Context $context): Result + { + $path = $context->getComponentPath(); + $message = $context->getOption('commit_message'); + $files = $context->getOption('files'); + + try { + // Pretend mode + if ($this->pretend) { + if (is_array($files)) { + return $this->pretend( + "Stage files: " . implode(', ', $files) . "\n" + . "Create commit: {$message}" + ); + } + return $this->pretend("Create commit: {$message}"); + } + + // Stage files if specified + if (is_array($files)) { + foreach ($files as $file) { + $this->output->info("Staging: {$file}"); + $this->gitHelper->add($path . '/' . ltrim($file, '/')); + } + } + + // Create commit + $this->output->info("Creating commit..."); + $this->gitHelper->commit($path, $message); + + // Get commit SHA for fact emission + $commitSha = $this->gitHelper->getLastCommitSha($path); + + // Emit fact for later tasks + $context->setFact('git.commit_sha', $commitSha); + + $this->output->ok("Created commit: {$commitSha}"); + + return Result::success( + "Commit created: " . substr($commitSha, 0, 7), + ['commit_sha' => $commitSha] + ); + } catch (Exception $e) { + $this->output->error("Commit failed: {$e->getMessage()}"); + return Result::failure("Commit failed: {$e->getMessage()}"); + } + } +} diff --git a/src/Task/Git/PushTask.php b/src/Task/Git/PushTask.php new file mode 100644 index 00000000..c6541937 --- /dev/null +++ b/src/Task/Git/PushTask.php @@ -0,0 +1,115 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\Git; + +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Horde\Components\Output; +use Horde\Components\Helper\Git as GitHelper; +use Exception; + +/** + * Push commits and tags to remote repository. + * + * Options: + * - remote: Remote name (default: 'origin') + * - branch: Branch name (default: current tracking branch) + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class PushTask extends AbstractTask +{ + /** + * Constructor. + * + * @param Output $output Output handler + * @param GitHelper $gitHelper Git operations helper + * @param bool $pretend Pretend (dry-run) mode + */ + public function __construct( + Output $output, + private readonly GitHelper $gitHelper, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + /** + * Get task name. + * + * @return string + */ + public function getName(): string + { + return 'Git Push'; + } + + /** + * Validate preconditions. + * + * @param Context $context Execution context + * @return array Error messages (empty if valid) + */ + public function validate(Context $context): array + { + $errors = []; + + $path = $context->getComponentPath(); + if (!is_dir($path . '/.git')) { + $errors[] = 'Not a git repository'; + } + + return $errors; + } + + /** + * Execute the task. + * + * @param Context $context Execution context + * @return Result + */ + public function run(Context $context): Result + { + $remote = $context->getOption('remote') ?? 'origin'; + $branch = $context->getOption('branch') ?? ''; + $path = $context->getComponentPath(); + + if ($this->pretend) { + return $this->pretend("Push to remote '{$remote}'" . ($branch ? " branch '{$branch}'" : '')); + } + + try { + $this->output->info("Pushing to remote '{$remote}'..."); + + // Push branch and tags + $this->gitHelper->push($path, $remote, $branch ?: null, false, true); + + $this->output->ok("Pushed to '{$remote}'"); + + return Result::success("Pushed to remote '{$remote}'"); + + } catch (Exception $e) { + $message = "Push failed: {$e->getMessage()}"; + $this->output->error($message); + return Result::failure($message); + } + } +} diff --git a/src/Task/Git/TagTask.php b/src/Task/Git/TagTask.php new file mode 100644 index 00000000..f7760d8d --- /dev/null +++ b/src/Task/Git/TagTask.php @@ -0,0 +1,127 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\Git; + +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Horde\Components\Output; +use Horde\Components\Helper\Git as GitHelper; +use Exception; + +/** + * Create a git tag. + * + * Options: + * - tag: Tag name (required) + * - message: Tag message (required) + * - force: Force tag creation (default: false) + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class TagTask extends AbstractTask +{ + /** + * Constructor. + * + * @param Output $output Output handler + * @param GitHelper $gitHelper Git operations helper + * @param bool $pretend Pretend (dry-run) mode + */ + public function __construct( + Output $output, + private readonly GitHelper $gitHelper, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + /** + * Get task name. + * + * @return string + */ + public function getName(): string + { + return 'Git Tag'; + } + + /** + * Validate preconditions. + * + * @param Context $context Execution context + * @return array Error messages (empty if valid) + */ + public function validate(Context $context): array + { + $errors = []; + + if (empty($context->getOption('tag'))) { + $errors[] = 'Tag name is required'; + } + + if (empty($context->getOption('message'))) { + $errors[] = 'Tag message is required'; + } + + $path = $context->getComponentPath(); + if (!is_dir($path . '/.git')) { + $errors[] = 'Not a git repository'; + } + + return $errors; + } + + /** + * Execute the task. + * + * @param Context $context Execution context + * @return Result + */ + public function run(Context $context): Result + { + $tag = $context->getOption('tag'); + $message = $context->getOption('message'); + $force = $context->getOption('force') ?? false; + $path = $context->getComponentPath(); + + if ($this->pretend) { + return $this->pretend("Create tag '{$tag}' with message '{$message}'"); + } + + try { + $this->output->info("Creating tag '{$tag}'..."); + + $this->gitHelper->tag($path, $tag, $message, $force); + + // Store tag name as a fact for other tasks + $context->setFact('git.tag', $tag); + + $this->output->ok("Tag '{$tag}' created"); + + return Result::success("Tag '{$tag}' created", ['tag' => $tag]); + + } catch (Exception $e) { + $message = "Tag creation failed: {$e->getMessage()}"; + $this->output->error($message); + return Result::failure($message); + } + } +} diff --git a/src/Task/GitHub/CreateGitHubReleaseTask.php b/src/Task/GitHub/CreateGitHubReleaseTask.php new file mode 100644 index 00000000..3f562373 --- /dev/null +++ b/src/Task/GitHub/CreateGitHubReleaseTask.php @@ -0,0 +1,199 @@ + + * @category Horde + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\GitHub; + +use Horde\Components\Helper\Git as GitHelper; +use Horde\Components\Helper\GitHubReleaseCreator; +use Horde\Components\Helper\Version; +use Horde\Components\Output; +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Exception; + +/** + * Create a GitHub release from a git tag. + * + * Creates a GitHub release with identity verification. If release exists + * with exact tag name and same commit hash, accepts it (retry scenario). + * Fails if tag name or commit differs. + * + * Required Facts: + * - version.tag_name (string) - Git tag format (e.g., 'v2.0.0') + * + * Required Options: + * - release_notes (string) - Formatted release notes + * + * Optional Facts: + * - version.is_prerelease (bool) - Mark as prerelease (default: false) + * - version.next (Version) - Version object for release name + * + * Emitted Facts: + * - github.release_created (bool) - True if release created + * - github.release_id (int) - GitHub release ID + * - github.release_url (string) - Release URL + * - github.tag_name (string) - Tag name + * + * @author Ralf Lang + * @category Horde + * @copyright 2024-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ +class CreateGitHubReleaseTask extends AbstractTask +{ + public function __construct( + Output $output, + private readonly GitHubReleaseCreator $githubReleaseCreator, + private readonly GitHelper $gitHelper, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + + public function getName(): string + { + return "Creat\1 \2i\1 \2u\1 \2elease"; + } + public function run(Context $context): Result + { + $componentPath = $context->getComponentPath(); + $tagName = $context->getFact('version.tag_name'); + $releaseNotes = $context->getOption('release_notes'); + + if ($tagName === null) { + throw new Exception('version.tag_name fact required. Run CalculateNextVersionTask first.'); + } + + if ($releaseNotes === null) { + throw new Exception('release_notes option required'); + } + + // Get version for release name + $version = $context->getFact('version.next'); + $releaseName = $version instanceof Version + ? $version->toFullSemverV2() + : $tagName; + + // Check if prerelease + $isPrerelease = (bool) $context->getFact('version.is_prerelease'); + + // Get commit hash - use the commit SHA from CommitTask instead of resolving the tag + // (the tag exists locally but may not be pushed yet) + $localCommitHash = $context->getFact('git.commit_sha'); + if (!$localCommitHash) { + // Fallback: get HEAD commit if no commit fact exists + $localCommitHash = $this->gitHelper->getLastCommitSha($componentPath); + } + + // Check if release exists (API read - runs in pretend) + $existingRelease = $this->githubReleaseCreator->getReleaseByTag($componentPath, $tagName); + + if ($existingRelease !== null) { + // Release exists - verify identity + return $this->verifyReleaseIdentity( + $context, + $existingRelease, + $tagName, + $localCommitHash + ); + } + + // No existing release - create new one + if ($this->pretend) { + return Result::success( + "Would create GitHub release for {$tagName}", + [ + 'tag_name' => $tagName, + 'prerelease' => $isPrerelease, + 'pretend' => true, + ] + ); + } + + $release = $this->githubReleaseCreator->createRelease( + localDir: $componentPath, + tagName: $tagName, + releaseName: $releaseName, + releaseBody: $releaseNotes, + prerelease: $isPrerelease + ); + + // Emit facts + $context->setFact('github.release_created', true); + $context->setFact('github.release_id', $release->id); + $context->setFact('github.release_url', $release->htmlUrl); + $context->setFact('github.tag_name', $tagName); + + return Result::success( + "Created GitHub release: {$release->htmlUrl}", + [ + 'release_id' => $release->id, + 'release_url' => $release->htmlUrl, + 'tag_name' => $tagName, + 'created' => true, + ] + ); + } + + /** + * Verify that existing release matches our identity criteria. + * + * @throws Exception if identity verification fails + */ + private function verifyReleaseIdentity( + Context $context, + object $existingRelease, + string $expectedTagName, + string $expectedCommitHash + ): Result { + // Check 1: Exact tag name match + if ($existingRelease->tagName !== $expectedTagName) { + throw new Exception( + "GitHub release exists with different tag name.\n" + . "Expected: {$expectedTagName}\n" + . "Found: {$existingRelease->tagName}\n" + . "This indicates a version normalization mismatch.\n" + . "To re-release, manually delete the release and tag first:\n" + . " gh release delete {$existingRelease->tagName} --yes\n" + . " git push origin :refs/tags/{$existingRelease->tagName}" + ); + } + + // Check 2: Same commit hash (note: GithubRelease doesn't store target_commitish) + // We can't verify commit match from the release object, so skip this check + // The tag verification in git is sufficient + + // Identity verified - emit facts for retry scenario + $context->setFact('github.release_created', false); + $context->setFact('github.release_id', $existingRelease->id); + $context->setFact('github.release_url', $existingRelease->htmlUrl); + $context->setFact('github.tag_name', $expectedTagName); + + return Result::skip( + "GitHub release already exists with matching identity: {$existingRelease->htmlUrl}", + [ + 'release_id' => $existingRelease->id, + 'release_url' => $existingRelease->htmlUrl, + 'tag_name' => $expectedTagName, + 'created' => false, + 'already_existed' => true, + ] + ); + } +} diff --git a/src/Task/GitHub/UploadGitHubAssetTask.php b/src/Task/GitHub/UploadGitHubAssetTask.php new file mode 100644 index 00000000..cfbc9ed6 --- /dev/null +++ b/src/Task/GitHub/UploadGitHubAssetTask.php @@ -0,0 +1,203 @@ + + * @category Horde + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\GitHub; + +use Horde\Components\Helper\GitHubReleaseCreator; +use Horde\Components\Output; +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Exception; + +/** + * Upload file as asset to GitHub release. + * + * Generic task for uploading any file to a GitHub release. Performs + * identity check (file size comparison) for retry safety. + * + * Required Facts: + * - github.release_id (int) - GitHub release ID + * + * Required Options (one of): + * - file_path (string) - Path to file to upload + * OR + * - phar.file_path (string) - From BuildPharTask + * + * Optional Options: + * - asset_name (string) - Override asset filename + * - content_type (string) - MIME type (default: 'application/octet-stream') + * + * Emitted Facts: + * - github.asset_uploaded (bool) - True if asset uploaded + * - github.asset_id (int) - GitHub asset ID + * - github.asset_url (string) - Browser download URL + * - github.asset_name (string) - Asset filename + * + * @author Ralf Lang + * @category Horde + * @copyright 2024-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ +class UploadGitHubAssetTask extends AbstractTask +{ + public function __construct( + Output $output, + private readonly GitHubReleaseCreator $githubReleaseCreator, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + public function shouldSkip(Context $context): bool + { + // Skip if no file to upload + $filePath = $context->getOption('file_path') + ?? $context->getFact('phar.file_path'); + + return $filePath === null || !file_exists($filePath); + } + + + public function getName(): string + { + return "Uploa\1 \2i\1 \2u\1 \2sset"; + } + public function run(Context $context): Result + { + $componentPath = $context->getComponentPath(); + + // Resolve file path + $filePath = $context->getOption('file_path') + ?? $context->getFact('phar.file_path'); + + if ($filePath === null || !file_exists($filePath)) { + throw new Exception('File to upload not found: ' . ($filePath ?? 'null')); + } + + if (!is_readable($filePath)) { + throw new Exception('File not readable: ' . $filePath); + } + + // Resolve asset name + $assetName = $context->getOption('asset_name') + ?? $context->getFact('phar.file_name') + ?? basename($filePath); + + // Get release info + $releaseId = $context->getFact('github.release_id'); + + if ($releaseId === null) { + throw new Exception( + 'github.release_id fact not found. Run CreateGitHubReleaseTask first.' + ); + } + + // Check if asset already exists + $existingAsset = $this->githubReleaseCreator->getAssetByName( + $componentPath, + $releaseId, + $assetName + ); + + if ($existingAsset !== null) { + // Asset exists - verify identity (size match) + $localSize = filesize($filePath); + + if ($localSize === $existingAsset->size) { + // Same size = probably same file (retry scenario) + $context->setFact('github.asset_uploaded', false); + $context->setFact('github.asset_id', $existingAsset->id); + $context->setFact('github.asset_url', $existingAsset->browser_download_url); + $context->setFact('github.asset_name', $assetName); + + return Result::skip( + "GitHub asset already exists: {$assetName}", + [ + 'asset_id' => $existingAsset->id, + 'asset_url' => $existingAsset->browser_download_url, + 'asset_name' => $assetName, + 'already_existed' => true, + ] + ); + } + + // Different size = different file (error) + throw new Exception( + "GitHub asset '{$assetName}' exists with different size.\n" + . "Local: {$localSize} bytes\n" + . "Remote: {$existingAsset->size} bytes\n" + . "Delete the existing release or asset before re-uploading." + ); + } + + // Upload asset + if ($this->pretend) { + $fileSize = filesize($filePath); + + return Result::success( + "Would upload {$assetName} (" . $this->formatBytes($fileSize) . ")", + [ + 'asset_name' => $assetName, + 'file_size' => $fileSize, + 'pretend' => true, + ] + ); + } + + $contentType = $context->getOption('content_type') ?? 'application/octet-stream'; + + $asset = $this->githubReleaseCreator->uploadAsset( + localDir: $componentPath, + releaseId: $releaseId, + filePath: $filePath, + assetName: $assetName, + contentType: $contentType + ); + + // Emit facts + $context->setFact('github.asset_uploaded', true); + $context->setFact('github.asset_id', $asset->id); + $context->setFact('github.asset_url', $asset->browser_download_url); + $context->setFact('github.asset_name', $assetName); + + return Result::success( + "Uploaded GitHub asset: {$asset->browser_download_url}", + [ + 'asset_id' => $asset->id, + 'asset_url' => $asset->browser_download_url, + 'asset_name' => $assetName, + 'asset_size' => $asset->size, + 'uploaded' => true, + ] + ); + } + + /** + * Format bytes to human-readable size. + */ + private function formatBytes(int $bytes): string + { + $units = ['B', 'KB', 'MB', 'GB']; + $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); + $pow = min($pow, count($units) - 1); + + $bytes /= (1 << (10 * $pow)); + + return round($bytes, 2) . ' ' . $units[$pow]; + } +} diff --git a/src/Task/Release/AddChangelogEntryTask.php b/src/Task/Release/AddChangelogEntryTask.php new file mode 100644 index 00000000..e519c4e2 --- /dev/null +++ b/src/Task/Release/AddChangelogEntryTask.php @@ -0,0 +1,234 @@ + + * @category Horde + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\Release; + +use Horde\Components\Helper\Git as GitHelper; +use Horde\Components\Helper\Version; +use Horde\Components\License; +use Horde\Components\Output; +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Horde\Components\Wrapper\ChangelogYml; +use Horde\Components\ChangelogEntry; +use Horde\HordeYmlFile\HordeYmlFile; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use Exception; + +/** + * Add a changelog entry to changelog.yml. + * + * Finds existing changelog.yml in doc/ tree, migrates to doc/changelog.yml + * if in nested location, adds new version entry. Fails if changelog.yml + * doesn't exist (runner must handle upgrade/init scenarios). + * + * Required Facts: + * - version.next (Version|string) - Next version object or string + * + * Required Options: + * - release_notes (string) - Formatted release notes + * + * Optional Options: + * - license (string) - Override license (default: from .horde.yml) + * + * Emitted Facts: + * - changelog.updated (bool) - True if entry added + * - changelog.migrated (bool) - True if file moved to standard location + * - changelog.file_path (string) - Path to changelog.yml + * - changelog.version (string) - Version string added + * + * @author Ralf Lang + * @category Horde + * @copyright 2024-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ +class AddChangelogEntryTask extends AbstractTask +{ + public function __construct( + Output $output, + private readonly GitHelper $gitHelper, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + + public function getName(): string + { + return "Ad\1 \2hangelo\1 \2ntry"; + } + public function run(Context $context): Result + { + $componentPath = $context->getComponentPath(); + $docPath = $componentPath . '/doc'; + + // Validate doc/ directory exists + if (!is_dir($docPath)) { + throw new Exception("Component missing doc/ directory: {$docPath}"); + } + + // Find changelog.yml + $changelogPath = $this->findChangelogYml($docPath); + + if ($changelogPath === null) { + throw new Exception( + "No changelog.yml found in doc/ directory. Cannot add changelog entry.\n" + . "This component may need to be upgraded from H5 format first.\n\n" + . "If this component has a CHANGES file, run:\n" + . " horde-components upgrade changelog\n\n" + . "Otherwise, create doc/changelog.yml manually." + ); + } + + // Migrate to H6 location if needed + $expectedPath = $componentPath . '/doc/changelog.yml'; + $migrated = false; + $movedFrom = null; + + if ($changelogPath !== $expectedPath) { + if (!$this->pretend) { + $this->gitHelper->moveFile($changelogPath, $expectedPath); + } + $migrated = true; + $movedFrom = $changelogPath; + $changelogPath = $expectedPath; + } + + // Resolve version + $version = $this->resolveVersion($context); + + // Get release notes + $releaseNotes = $context->getOption('release_notes'); + + if ($releaseNotes === null) { + throw new Exception('release_notes option required'); + } + + // Get license + $license = $context->getOption('license'); + + if ($license === null) { + // Default from .horde.yml + $hordeYmlPath = $componentPath . '/.horde.yml'; + if (file_exists($hordeYmlPath)) { + $hordeYml = new HordeYmlFile($hordeYmlPath); + $licenseObj = $hordeYml->getLicense(); + $license = $licenseObj->identifier ?? 'LGPL-2.1'; + } else { + $license = 'LGPL-2.1'; + } + } + + // Add changelog entry + if (!$this->pretend) { + $changelog = new ChangelogYml($componentPath . '/doc'); + + $entry = new ChangelogEntry( + releaseVersion: $version, + apiVersion: $version, + license: License::fromIdentifier($license), + notes: $releaseNotes + ); + + $changelog->addChangelogEntry($entry); + $changelog->save(); + } + + // Emit facts + $context->setFact('changelog.updated', true); + $context->setFact('changelog.migrated', $migrated); + $context->setFact('changelog.file_path', $expectedPath); + $context->setFact('changelog.version', $version->toFullSemverV2()); + + // Build message + $action = $this->pretend ? 'Would add' : 'Added'; + $message = "{$action} changelog entry for version {$version->toFullSemverV2()}"; + + if ($migrated && !$this->pretend) { + $message .= " (migrated from {$movedFrom})"; + } elseif ($migrated && $this->pretend) { + $message .= " (would migrate from {$movedFrom})"; + } + + return Result::success($message, [ + 'file' => 'doc/changelog.yml', + 'version' => $version->toFullSemverV2(), + 'migrated' => $migrated, + 'migrated_from' => $movedFrom, + 'entry_added' => !$this->pretend, + ]); + } + + /** + * Find changelog.yml in doc/ directory or subdirectories. + * + * @param string $docPath Path to doc/ directory + * @return string|null Absolute path to changelog.yml, or null if not found + */ + private function findChangelogYml(string $docPath): ?string + { + // Check expected location first (fast path) + $expectedPath = $docPath . '/changelog.yml'; + if (file_exists($expectedPath)) { + return $expectedPath; + } + + // Search subdirectories (e.g., doc/lib/Horde/Component/changelog.yml) + try { + $iterator = new RecursiveDirectoryIterator($docPath); + $recursive = new RecursiveIteratorIterator($iterator); + + foreach ($recursive as $file) { + if ($file->getFilename() === 'changelog.yml') { + return $file->getPathname(); + } + } + } catch (Exception $e) { + // Directory iteration failed + return null; + } + + return null; + } + + /** + * Resolve version from context (object or string). + */ + private function resolveVersion(Context $context): Version + { + // Try object fact first (from pipeline) + $versionObj = $context->getFact('version.next'); + + if ($versionObj instanceof Version) { + return $versionObj; + } + + // Try string fact or option (from CLI) + $versionStr = $context->getOption('version') + ?? $context->getFact('version.next_string'); + + if ($versionStr !== null && is_string($versionStr)) { + return Version::fromComposerString($versionStr); + } + + throw new Exception( + 'version.next fact or --version option required. Run CalculateNextVersionTask first.' + ); + } +} diff --git a/src/Task/Release/CleanupLegacyFilesTask.php b/src/Task/Release/CleanupLegacyFilesTask.php new file mode 100644 index 00000000..c5eb942b --- /dev/null +++ b/src/Task/Release/CleanupLegacyFilesTask.php @@ -0,0 +1,144 @@ + + * @category Horde + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\Release; + +use Horde\Components\Helper\Git as GitHelper; +use Horde\Components\Output; +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use Exception; + +/** + * Delete legacy H5 files that are no longer needed in H6. + * + * Removes package.xml and CHANGES files. This task assumes CHANGES has + * already been migrated to changelog.yml if needed. The runner is + * responsible for migration before calling this task. + * + * Files Removed: + * - package.xml (PEAR package descriptor, replaced by composer.json) + * - doc/CHANGES (old changelog format, replaced by changelog.yml) + * + * Emitted Facts: + * - files.deleted (array) - List of deleted files + * - files.deleted_count (int) - Number of files deleted + * - legacy_files.cleaned (bool) - True if cleanup completed + * + * @author Ralf Lang + * @category Horde + * @copyright 2024-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ +class CleanupLegacyFilesTask extends AbstractTask +{ + public function __construct( + Output $output, + private readonly GitHelper $gitHelper, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + + public function getName(): string + { + return "Cleanu\1 \2egac\1 \2iles"; + } + public function run(Context $context): Result + { + $componentPath = $context->getComponentPath(); + $deleted = []; + + // Delete package.xml if exists + $packageXml = $componentPath . '/package.xml'; + if (file_exists($packageXml)) { + if (!$this->pretend) { + $this->gitHelper->deleteFile($packageXml); + } + $deleted[] = 'package.xml'; + } + + // Search for CHANGES file (may be nested in doc/ subdirectories) + $changesFile = $this->findChangesFile($componentPath . '/doc'); + if ($changesFile !== null) { + if (!$this->pretend) { + $this->gitHelper->deleteFile($changesFile); + } + $deleted[] = str_replace($componentPath . '/', '', $changesFile); + } + + // Emit facts + $context->setFact('files.deleted', $deleted); + $context->setFact('files.deleted_count', count($deleted)); + $context->setFact('legacy_files.cleaned', true); + + if (empty($deleted)) { + return Result::success('No legacy files to clean up', ['deleted' => []]); + } + + $action = $this->pretend ? 'Would delete' : 'Deleted'; + $filesList = implode(', ', $deleted); + + return Result::success( + "{$action} legacy files: {$filesList}", + [ + 'deleted' => $deleted, + 'count' => count($deleted), + ] + ); + } + + /** + * Find CHANGES file in doc/ directory or subdirectories. + * + * @param string $docPath Path to doc/ directory + * @return string|null Absolute path to CHANGES file, or null if not found + */ + private function findChangesFile(string $docPath): ?string + { + if (!is_dir($docPath)) { + return null; + } + + // Check expected location first + $expectedPath = $docPath . '/CHANGES'; + if (file_exists($expectedPath)) { + return $expectedPath; + } + + // Search subdirectories (e.g., doc/lib/Horde/Component/CHANGES) + try { + $iterator = new RecursiveDirectoryIterator($docPath); + $recursive = new RecursiveIteratorIterator($iterator); + + foreach ($recursive as $file) { + if ($file->getFilename() === 'CHANGES') { + return $file->getPathname(); + } + } + } catch (Exception $e) { + // Directory iteration failed + return null; + } + + return null; + } +} diff --git a/src/Task/Release/UpdateApplicationSentinelTask.php b/src/Task/Release/UpdateApplicationSentinelTask.php new file mode 100644 index 00000000..a8bd0665 --- /dev/null +++ b/src/Task/Release/UpdateApplicationSentinelTask.php @@ -0,0 +1,150 @@ + + * @category Horde + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\Release; + +use Horde\Components\Helper\Version; +use Horde\Components\Output; +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Horde\Components\Wrapper\ApplicationPhp; +use Exception; + +/** + * Update version sentinel in Application.php for Horde applications. + * + * Updates the version constant in lib/Application.php and/or src/Application.php + * for Horde applications. Handles both PSR-0 and PSR-4 locations gracefully. + * + * Required Facts: + * - version.next (Version|string) - Next version object or string + * + * Emitted Facts: + * - application.sentinel_updated (bool) - True if any sentinel updated + * - application.files_updated (array) - List of files updated + * - application.version (string) - Version set + * + * @author Ralf Lang + * @category Horde + * @copyright 2024-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ +class UpdateApplicationSentinelTask extends AbstractTask +{ + public function __construct( + Output $output, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + public function shouldSkip(Context $context): bool + { + // Skip if this is a library (no Application.php in either location) + $componentPath = $context->getComponentPath(); + $legacyPath = $componentPath . '/lib/Application.php'; + $modernPath = $componentPath . '/src/Application.php'; + + return !file_exists($legacyPath) && !file_exists($modernPath); + } + + + public function getName(): string + { + return "Updat\1 \2pplicatio\1 \2entinel"; + } + public function run(Context $context): Result + { + $componentPath = $context->getComponentPath(); + + // Resolve version + $version = $this->resolveVersion($context); + $versionString = $version->toFullSemverV2(); + + $legacyPath = $componentPath . '/lib/Application.php'; + $modernPath = $componentPath . '/src/Application.php'; + + $updatedFiles = []; + + // Update lib/Application.php if exists (PSR-0 or mid-migration) + if (file_exists($legacyPath)) { + if (!$this->pretend) { + $applicationPhp = new ApplicationPhp($componentPath, 'lib'); + $applicationPhp->setVersion($versionString); + $applicationPhp->save(); + } + $updatedFiles[] = 'lib/Application.php'; + } + + // Update src/Application.php if exists (PSR-4 or mid-migration) + if (file_exists($modernPath)) { + if (!$this->pretend) { + $applicationPhp = new ApplicationPhp($componentPath, 'src'); + $applicationPhp->setVersion($versionString); + $applicationPhp->save(); + } + $updatedFiles[] = 'src/Application.php'; + } + + // This should never happen due to shouldSkip(), but defensive programming + if (empty($updatedFiles)) { + throw new Exception('No Application.php found in lib/ or src/'); + } + + // Emit facts + $context->setFact('application.sentinel_updated', true); + $context->setFact('application.files_updated', $updatedFiles); + $context->setFact('application.version', $versionString); + + $action = $this->pretend ? 'Would update' : 'Updated'; + $filesList = implode(', ', $updatedFiles); + + return Result::success( + "{$action} application sentinel: {$filesList} to version {$versionString}", + [ + 'files' => $updatedFiles, + 'version' => $versionString, + ] + ); + } + + /** + * Resolve version from context (object or string). + */ + private function resolveVersion(Context $context): Version + { + // Try object fact first (from pipeline) + $versionObj = $context->getFact('version.next'); + + if ($versionObj instanceof Version) { + return $versionObj; + } + + // Try string fact or option (from CLI) + $versionStr = $context->getOption('version') + ?? $context->getFact('version.next_string'); + + if ($versionStr !== null && is_string($versionStr)) { + return Version::fromComposerString($versionStr); + } + + throw new Exception( + 'version.next fact or --version option required. Run CalculateNextVersionTask first.' + ); + } +} diff --git a/src/Task/Release/UpdateChangelogTimestampTask.php b/src/Task/Release/UpdateChangelogTimestampTask.php new file mode 100644 index 00000000..4915a486 --- /dev/null +++ b/src/Task/Release/UpdateChangelogTimestampTask.php @@ -0,0 +1,134 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\Release; + +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Horde\Components\Output; +use Horde\Components\Helper\ChangeLog as ChangeLogHelper; +use Horde\Components\Exception; + +/** + * Update changelog.yml timestamp for the current release version. + * + * This task sets the release date in changelog.yml to today's date. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class UpdateChangelogTimestampTask extends AbstractTask +{ + /** + * Constructor. + * + * @param Output $output Output handler + * @param bool $pretend Pretend (dry-run) mode + */ + public function __construct( + Output $output, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + /** + * Get task name. + * + * @return string + */ + public function getName(): string + { + return 'Update Changelog Timestamp'; + } + + /** + * Validate preconditions. + * + * @param Context $context Execution context + * @return array Error messages (empty if valid) + */ + public function validate(Context $context): array + { + $errors = []; + + $component = $context->component; + + // Check if changelog.yml exists + try { + if (!$component->getWrapper('ChangelogYml')->exists()) { + $errors[] = 'The component lacks a changelog.yml!'; + } + } catch (\Exception $e) { + $errors[] = 'Failed to check changelog.yml: ' . $e->getMessage(); + } + + return $errors; + } + + /** + * Execute the task. + * + * @param Context $context Execution context + * @return Result + */ + public function run(Context $context): Result + { + $component = $context->component; + + try { + // Get the changelog helper + $helper = $component->getFactory()->createChangelog($component); + + // Get the changelog file path + $changelogFile = $helper->changelogFileExists(); + + if ($this->pretend) { + return $this->pretend("Would timestamp {$changelogFile} now"); + } + + // Update the timestamp + $helper->timestamp(); + + // Save the changelog + $component->getWrapper('ChangelogYml')->save(); + + $message = "Marked {$changelogFile} with current timestamp."; + $this->output->ok($message); + + // Store the changelog file path as a fact + $context->setFact('changelog.file', $changelogFile); + $context->setFact('changelog.timestamped', true); + + return Result::success($message, [ + 'file' => $changelogFile, + 'date' => gmdate('Y-m-d'), + ]); + + } catch (Exception $e) { + $message = "Timestamp update failed: {$e->getMessage()}"; + $this->output->error($message); + return Result::failure($message); + } catch (\Exception $e) { + $message = "Timestamp update failed: {$e->getMessage()}"; + $this->output->error($message); + return Result::failure($message); + } + } +} diff --git a/src/Task/Release/UpdateComposerJsonTask.php b/src/Task/Release/UpdateComposerJsonTask.php new file mode 100644 index 00000000..d0181a0d --- /dev/null +++ b/src/Task/Release/UpdateComposerJsonTask.php @@ -0,0 +1,116 @@ + + * @category Horde + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\Release; + +use Horde\Components\Helper\Composer as ComposerHelper; +use Horde\Components\Helper\Git as GitHelper; +use Horde\Components\Helper\Version; +use Horde\Components\Output; +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Horde\Components\Wrapper\HordeYml; +use Exception; + +/** + * Update composer.json from .horde.yml metadata. + * + * Regenerates composer.json with updated version and dependencies from + * .horde.yml configuration. + * + * Required Facts: + * - version.next (Version|string) - Next version object or string + * + * Optional Options: + * - composer.version (string) - Composer version string (e.g., 'dev-FRAMEWORK_6_0') + * For backwards compatibility, also accepts: composer_version + * + * Emitted Facts: + * - composer_json.updated (bool) - True if file updated + * - composer_json.file_path (string) - Path to composer.json + * - composer_json.version (string) - Version set in composer.json + * + * @author Ralf Lang + * @category Horde + * @copyright 2024-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ +class UpdateComposerJsonTask extends AbstractTask +{ + public function __construct( + Output $output, + private readonly ComposerHelper $composerHelper, + private readonly GitHelper $gitHelper, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + + public function getName(): string + { + return "Update Composer Json"; + } + public function run(Context $context): Result + { + $componentPath = $context->getComponentPath(); + + // Load .horde.yml + $hordeYml = new HordeYml($componentPath); + + // Determine composer version + // Try new name first, then fall back to old name for backwards compatibility + $composerVersion = $context->getOption('composer.version') ?? $context->getOption('composer_version'); + + if ($composerVersion === null) { + // Use branch-based version (e.g., 'dev-FRAMEWORK_6_0') + $branch = $this->getCurrentBranch($componentPath); + $composerVersion = $branch ? "dev-{$branch}" : 'dev-main'; + } + + // Generate composer.json + if (!$this->pretend) { + $this->composerHelper->generateComposerJson( + $hordeYml, + ['composer.version' => $composerVersion] + ); + } + + // Emit facts + $context->setFact('composer_json.updated', true); + $context->setFact('composer_json.file_path', $componentPath . '/composer.json'); + $context->setFact('composer_json.version', $composerVersion); + + $action = $this->pretend ? 'Would update' : 'Updated'; + return Result::success( + "{$action} composer.json (version: {$composerVersion})", + [ + 'file_path' => $componentPath . '/composer.json', + 'version' => $composerVersion, + ] + ); + } + + /** + * Get current git branch. + */ + private function getCurrentBranch(string $componentPath): ?string + { + return $this->gitHelper->getCurrentBranch($componentPath); + } +} diff --git a/src/Task/Release/UpdateHordeYmlVersionTask.php b/src/Task/Release/UpdateHordeYmlVersionTask.php new file mode 100644 index 00000000..e7fcdf4f --- /dev/null +++ b/src/Task/Release/UpdateHordeYmlVersionTask.php @@ -0,0 +1,123 @@ + + * @category Horde + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\Release; + +use Horde\Components\Helper\Version; +use Horde\Components\Output; +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Horde\Components\Wrapper\HordeYml; +use Exception; + +/** + * Update .horde.yml with new release version and API version. + * + * Required Facts: + * - version.next (Version|string) - Next version object or string + * + * Optional Options: + * - api_version (string) - Override API version (default: same as release version) + * + * Emitted Facts: + * - horde_yml.updated (bool) - True if file updated + * - horde_yml.file_path (string) - Path to .horde.yml + * - horde_yml.version (string) - Release version set + * - horde_yml.api_version (string) - API version set + * + * @author Ralf Lang + * @category Horde + * @copyright 2024-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ +class UpdateHordeYmlVersionTask extends AbstractTask +{ + public function __construct( + Output $output, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + + public function getName(): string + { + return "Updat\1 \2ord\1 \2m\1 \2ersion"; + } + public function run(Context $context): Result + { + $componentPath = $context->getComponentPath(); + + // Resolve version (accept object or string) + $version = $this->resolveVersion($context); + + // Get API version (default: same as release version) + $apiVersion = $context->getOption('api_version') + ?? $version->toFullSemverV2(); + + // Load .horde.yml + $hordeYml = new HordeYml($componentPath); + + // Update version + if (!$this->pretend) { + $hordeYml->setReleaseVersionAndStability($version); + $hordeYml->save(); + } + + // Emit facts + $context->setFact('horde_yml.updated', true); + $context->setFact('horde_yml.file_path', $componentPath . '/.horde.yml'); + $context->setFact('horde_yml.version', $version->toFullSemverV2()); + $context->setFact('horde_yml.api_version', $apiVersion); + + $action = $this->pretend ? 'Would update' : 'Updated'; + return Result::success( + "{$action} .horde.yml to version {$version->toFullSemverV2()}", + [ + 'file_path' => $componentPath . '/.horde.yml', + 'version' => $version->toFullSemverV2(), + 'api_version' => $apiVersion, + ] + ); + } + + /** + * Resolve version from context (object or string). + */ + private function resolveVersion(Context $context): Version + { + // Try object fact first (from pipeline) + $versionObj = $context->getFact('version.next'); + + if ($versionObj instanceof Version) { + return $versionObj; + } + + // Try string fact or option (from CLI) + $versionStr = $context->getOption('version') + ?? $context->getFact('version.next_string'); + + if ($versionStr !== null && is_string($versionStr)) { + return Version::fromComposerString($versionStr); + } + + throw new Exception( + 'version.next fact or --version option required. Run CalculateNextVersionTask first.' + ); + } +} diff --git a/src/Task/Result.php b/src/Task/Result.php new file mode 100644 index 00000000..4d5c2145 --- /dev/null +++ b/src/Task/Result.php @@ -0,0 +1,180 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Task; + +/** + * Result of task execution. + * + * Standardizes task return values across all task types. + * Provides rich information about success, failure, warnings, + * and additional metadata. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +class Result +{ + /** + * Constructor. + * + * @param Status $status Execution status + * @param string $message Human-readable message + * @param int $errorCount Number of errors (for QC tasks) + * @param int $warningCount Number of warnings (for QC tasks) + * @param array $metadata Additional data for pipeline use + */ + public function __construct( + public readonly Status $status, + public readonly string $message = '', + public readonly int $errorCount = 0, + public readonly int $warningCount = 0, + public readonly array $metadata = [] + ) {} + + /** + * Create a success result. + * + * @param string $message Success message + * @param array $metadata Additional data + * @return self + */ + public static function success(string $message = '', array $metadata = []): self + { + return new self(Status::SUCCESS, $message, 0, 0, $metadata); + } + + /** + * Create a failure result. + * + * @param string $message Failure message + * @param int $errorCount Number of errors + * @return self + */ + public static function failure(string $message, int $errorCount = 1): self + { + return new self(Status::FAILURE, $message, $errorCount, 0, []); + } + + /** + * Create a warning result. + * + * Task completed but with non-blocking issues. + * + * @param string $message Warning message + * @param int $warningCount Number of warnings + * @param array $metadata Additional data + * @return self + */ + public static function warning( + string $message, + int $warningCount = 1, + array $metadata = [] + ): self { + return new self(Status::WARNING, $message, 0, $warningCount, $metadata); + } + + /** + * Create a skipped result. + * + * @param string $reason Skip reason + * @return self + */ + public static function skipped(string $reason): self + { + return new self(Status::SKIPPED, $reason, 0, 0, []); + } + + /** + * Create a QC result (may have errors and/or warnings). + * + * @param int $errorCount Number of errors + * @param int $warningCount Number of warnings + * @param string $message Result message + * @return self + */ + public static function qc( + int $errorCount, + int $warningCount = 0, + string $message = '' + ): self { + $status = $errorCount > 0 ? Status::FAILURE : Status::SUCCESS; + + if ($message === '') { + if ($errorCount === 0 && $warningCount === 0) { + $message = 'No issues found'; + } elseif ($errorCount > 0) { + $message = "{$errorCount} error(s) found"; + } else { + $message = "{$warningCount} warning(s) found"; + } + } + + return new self($status, $message, $errorCount, $warningCount, []); + } + + /** + * Check if task succeeded. + * + * @return bool True if success or warning + */ + public function isSuccess(): bool + { + return $this->status === Status::SUCCESS || $this->status === Status::WARNING; + } + + /** + * Check if task failed (blocking error). + * + * @return bool True if failure + */ + public function isFailure(): bool + { + return $this->status === Status::FAILURE; + } + + /** + * Check if task was skipped. + * + * @return bool True if skipped + */ + public function isSkipped(): bool + { + return $this->status === Status::SKIPPED; + } + + /** + * Check if task had any issues (errors or warnings). + * + * @return bool True if errors or warnings present + */ + public function hasIssues(): bool + { + return $this->errorCount > 0 || $this->warningCount > 0; + } + + /** + * Check if pipeline should stop after this task. + * + * @return bool True if task failed + */ + public function shouldStopPipeline(): bool + { + return $this->status === Status::FAILURE; + } +} diff --git a/src/Task/Status.php b/src/Task/Status.php new file mode 100644 index 00000000..a930ae4b --- /dev/null +++ b/src/Task/Status.php @@ -0,0 +1,50 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Task; + +/** + * Task execution status. + * + * Indicates the outcome of a task execution. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +enum Status: string +{ + /** + * Task succeeded without issues. + */ + case SUCCESS = 'success'; + + /** + * Task failed (blocking error). + */ + case FAILURE = 'failure'; + + /** + * Task completed with warnings (non-blocking). + */ + case WARNING = 'warning'; + + /** + * Task was skipped. + */ + case SKIPPED = 'skipped'; +} diff --git a/src/Task/TaskInterface.php b/src/Task/TaskInterface.php new file mode 100644 index 00000000..ce9654b6 --- /dev/null +++ b/src/Task/TaskInterface.php @@ -0,0 +1,73 @@ + + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ + +declare(strict_types=1); + +namespace Horde\Components\Task; + +/** + * Task interface for all task types. + * + * Tasks are discrete units of work that can be validated, executed, + * and composed into pipelines. Tasks can emit facts for later tasks, + * support pretend (dry-run) mode, and return structured results. + * + * @category Horde + * @package Components + * @author Ralf Lang + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + */ +interface TaskInterface +{ + /** + * Get task name for display. + * + * @return string Human-readable task name + */ + public function getName(): string; + + /** + * Validate preconditions before running the task. + * + * This is called before execution to check if all requirements + * are met (e.g., required tools installed, required facts present). + * + * @param Context $context Execution context + * @return array Empty if valid, error messages otherwise + */ + public function validate(Context $context): array; + + /** + * Check if this task should be skipped. + * + * Called after validation passes. Allows tasks to skip based on + * context or options (e.g., skip if fact already set). + * + * @param Context $context Execution context + * @return bool True to skip, false to run + */ + public function shouldSkip(Context $context): bool; + + /** + * Execute the task. + * + * Performs the task's work and returns a result indicating success, + * failure, or warnings. Tasks can emit facts via $context->setFact() + * for use by later tasks in the pipeline. + * + * @param Context $context Execution context + * @return Result Result object with success/failure and details + */ + public function run(Context $context): Result; +} diff --git a/src/Task/Version/AnalyzeConventionalCommitsTask.php b/src/Task/Version/AnalyzeConventionalCommitsTask.php new file mode 100644 index 00000000..07264afb --- /dev/null +++ b/src/Task/Version/AnalyzeConventionalCommitsTask.php @@ -0,0 +1,166 @@ + + * @category Horde + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\Version; + +use Horde\Components\Helper\Git as GitHelper; +use Horde\Components\Output; +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Exception; + +/** + * Analyze conventional commits to determine version bump severity. + * + * Parses git commits since the last tag to determine the highest severity + * change (BREAKING, feat, fix) for semantic versioning. + * + * Required Facts: None + * + * Emitted Facts: + * - version.top_severity (string) - Highest severity: 'major', 'minor', 'patch' + * - commits.analyzed_count (int) - Number of commits analyzed + * - commits.analyzed (bool) - True if analysis completed + * - notes.formatted (string) - Formatted release notes from commits + * + * @author Ralf Lang + * @category Horde + * @copyright 2024-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ +class AnalyzeConventionalCommitsTask extends AbstractTask +{ + public function __construct( + Output $output, + private readonly GitHelper $gitHelper, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + public function getName(): string + { + return 'Analyze Conventional Commits'; + } + + public function run(Context $context): Result + { + $componentPath = $context->getComponentPath(); + + // Get commits since last tag + $lastTag = $this->gitHelper->getLastTag($componentPath); + $commitRange = $lastTag ? "{$lastTag}..HEAD" : 'HEAD'; + + $result = $this->gitHelper->run( + "log {$commitRange} --pretty=format:'%H|||%s|||%b'", + $componentPath + ); + + if ($result->getReturnValue() !== 0) { + throw new Exception('Failed to get git commits: ' . $result->getOutputString()); + } + + $commitLines = array_filter(explode("\n", trim($result->getOutputString()))); + + if (empty($commitLines)) { + throw new Exception( + 'No conventional commits found since last tag. Cannot determine version bump.' + ); + } + + // Parse commits + $commits = []; + $topSeverity = 'patch'; + $formattedNotes = []; + + foreach ($commitLines as $line) { + $parts = explode('|||', $line); + if (count($parts) < 2) { + continue; + } + + $hash = $parts[0]; + $subject = $parts[1]; + $body = $parts[2] ?? ''; + + // Parse conventional commit format + $severity = $this->parseCommitSeverity($subject, $body); + + // Track highest severity + if ($severity === 'major' || ($severity === 'minor' && $topSeverity === 'patch')) { + $topSeverity = $severity; + } elseif ($severity === 'minor') { + $topSeverity = 'minor'; + } + + $commits[] = [ + 'hash' => $hash, + 'subject' => $subject, + 'severity' => $severity, + ]; + + // Add to formatted notes + $formattedNotes[] = $subject; + } + + $notes = implode("\n", $formattedNotes); + + // Emit facts + $context->setFact('version.top_severity', $topSeverity); + $context->setFact('commits.analyzed_count', count($commits)); + $context->setFact('commits.analyzed', true); + $context->setFact('notes.formatted', $notes); + + return Result::success( + "Analyzed " . count($commits) . " commits: {$topSeverity} bump", + [ + 'count' => count($commits), + 'top_severity' => $topSeverity, + 'commits' => $commits, + ] + ); + } + + /** + * Parse commit subject and body to determine severity. + * + * @param string $subject Commit subject line + * @param string $body Commit body + * @return string Severity: 'major', 'minor', or 'patch' + */ + private function parseCommitSeverity(string $subject, string $body): string + { + // Check for BREAKING CHANGE in body + if (str_contains($body, 'BREAKING CHANGE:') || str_contains($body, 'BREAKING-CHANGE:')) { + return 'major'; + } + + // Check for breaking change indicator in subject (!) + if (preg_match('/^[a-z]+(\([^)]+\))?!:/', $subject)) { + return 'major'; + } + + // Check for feat: + if (preg_match('/^feat(\([^)]+\))?:/', $subject)) { + return 'minor'; + } + + // Everything else is patch (fix, chore, docs, etc.) + return 'patch'; + } +} diff --git a/src/Task/Version/CalculateNextVersionTask.php b/src/Task/Version/CalculateNextVersionTask.php new file mode 100644 index 00000000..70ed1dd7 --- /dev/null +++ b/src/Task/Version/CalculateNextVersionTask.php @@ -0,0 +1,193 @@ + + * @category Horde + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ + +declare(strict_types=1); + +namespace Horde\Components\Task\Version; + +use Horde\Components\Helper\Git as GitHelper; +use Horde\Components\Helper\Version; +use Horde\Components\Output; +use Horde\Components\Task\AbstractTask; +use Horde\Components\Task\Context; +use Horde\Components\Task\Result; +use Exception; + +/** + * Calculate or normalize the next semantic version with reality check. + * + * Accepts manual version override via next_version option, or calculates + * from conventional commits. Both paths verify that the tag doesn't already + * exist locally or remotely. + * + * Required Facts: + * - version.top_severity (string) - Only if no manual version provided + * + * Optional Facts: + * - current_version (string) - Base version (default: from .horde.yml) + * + * Options: + * - next_version (string) - Manual version override + * - version_part (string) - Force bump type: 'minor' or 'patch' + * - allow_existing_tag (bool) - Skip tag check (default: false) + * + * Emitted Facts: + * - version.next (Version) - Version object + * - version.next_string (string) - Full SemVer string + * - version.tag_name (string) - Git tag format + * - version.stability (string) - Stability level + * - version.is_prerelease (bool) - Is prerelease version + * - version.source (string) - How determined: 'manual', 'calculated', 'forced' + * - version.tag_checked (bool) - Reality check passed + * + * @author Ralf Lang + * @category Horde + * @copyright 2024-2026 Horde LLC + * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 + * @package Components + */ +class CalculateNextVersionTask extends AbstractTask +{ + public function __construct( + Output $output, + private readonly GitHelper $gitHelper, + bool $pretend = false + ) { + parent::__construct($output, $pretend); + } + + public function getName(): string + { + return 'Calculate Next Version'; + } + + public function run(Context $context): Result + { + $componentPath = $context->getComponentPath(); + + // Determine version source and calculate + $manualVersion = $context->getOption('next_version'); + $versionPart = $context->getOption('version_part'); + + if ($manualVersion !== null) { + // Path A: Manual version + $version = Version::fromComposerString($manualVersion); + $source = 'manual'; + } elseif ($versionPart !== null) { + // Path C: Force version part bump + $version = $this->bumpVersionPart($context, $versionPart); + $source = 'forced'; + } else { + // Path B: Calculate from commits + $version = $this->calculateFromCommits($context); + $source = 'calculated'; + } + + $tagName = $version->toHordeTag(); + + // Reality check: tag must not already exist + $allowExisting = (bool) $context->getOption('allow_existing_tag'); + + if (!$allowExisting) { + $this->checkTagDoesNotExist($componentPath, $tagName, $version); + } + + // Emit facts + $context->setFact('version.next', $version); + $context->setFact('version.next_string', $version->toFullSemverV2()); + $context->setFact('version.tag_name', $tagName); + $context->setFact('version.stability', $version->getStability()); + $context->setFact('version.is_prerelease', in_array($version->getStability(), ['alpha', 'beta', 'RC'])); + $context->setFact('version.source', $source); + $context->setFact('version.tag_checked', true); + + return Result::success( + "Version: {$version->toFullSemverV2()} (tag: {$tagName}, source: {$source})", + [ + 'version' => $version, + 'version_string' => $version->toFullSemverV2(), + 'tag_name' => $tagName, + 'stability' => $version->getStability(), + 'is_prerelease' => in_array($version->getStability(), ['alpha', 'beta', 'RC']), + 'source' => $source, + 'tag_check_passed' => true, + ] + ); + } + + /** + * Calculate version from conventional commits. + */ + private function calculateFromCommits(Context $context): Version + { + $topSeverity = $context->getFact('version.top_severity'); + + if ($topSeverity === null) { + throw new Exception( + 'version.top_severity fact not found. Run AnalyzeConventionalCommitsTask first.' + ); + } + + $currentVersionStr = $context->getOption('current_version') + ?? $context->component->getVersion(); + + $currentVersion = Version::fromComposerString($currentVersionStr); + + // Bump based on severity + return $currentVersion->nextVersionObject($topSeverity); + } + + /** + * Bump specific version part. + */ + private function bumpVersionPart(Context $context, string $part): Version + { + if (!in_array($part, ['minor', 'patch'])) { + throw new Exception("Invalid version_part: {$part}. Must be 'minor' or 'patch'."); + } + + $currentVersionStr = $context->getOption('current_version') + ?? $context->component->getVersion(); + + $currentVersion = Version::fromComposerString($currentVersionStr); + + return $currentVersion->nextVersionObject($part); + } + + /** + * Check that tag doesn't already exist locally or remotely. + * + * @throws Exception if tag exists + */ + private function checkTagDoesNotExist(string $componentPath, string $tagName, Version $version): void + { + // Check local tag + if ($this->gitHelper->localTagExists($componentPath, $tagName)) { + throw new Exception( + "Tag '{$tagName}' already exists locally. Cannot release version {$version->toFullSemverV2()} again." + ); + } + + // Check remote tag (if remotes configured) + if ($this->gitHelper->hasRemotes($componentPath)) { + if ($this->gitHelper->remoteTagExists($componentPath, $tagName)) { + $nextSuggestion = $version->nextVersionObject('patch')->toFullSemverV2(); + throw new Exception( + "Tag '{$tagName}' already exists on remote. Cannot release version {$version->toFullSemverV2()} again.\n" + . "Suggestion: Use --next-version to specify a different version (e.g., --next-version {$nextSuggestion})" + ); + } + } + } +} diff --git a/src/Website/PageGenerator.php b/src/Website/PageGenerator.php index 362c3769..a40bc4d3 100644 --- a/src/Website/PageGenerator.php +++ b/src/Website/PageGenerator.php @@ -15,6 +15,7 @@ use RuntimeException; use Horde\HordeYmlFile\HordeYmlFile; +use Exception; /** * Full page generator for dev.horde.org @@ -126,7 +127,7 @@ private function loadHordeYml(string $componentName): ?HordeYmlFile if (file_exists($path)) { try { return new HordeYmlFile($path); - } catch (\Exception $e) { + } catch (Exception $e) { // Ignore parse errors, return null return null; } diff --git a/src/Wrapper/ApplicationPhp.php b/src/Wrapper/ApplicationPhp.php index a71e7ab3..6d64dad2 100644 --- a/src/Wrapper/ApplicationPhp.php +++ b/src/Wrapper/ApplicationPhp.php @@ -16,6 +16,7 @@ use Horde\Components\Wrapper; use Horde\Components\WrapperTrait; +use Stringable; /** * Wrapper for the Application.php/Bundle.php files. @@ -25,7 +26,7 @@ * @author Jan Schneider * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ -class ApplicationPhp implements Wrapper, \Stringable +class ApplicationPhp implements Wrapper, Stringable { use WrapperTrait; diff --git a/src/Wrapper/ChangelogYml.php b/src/Wrapper/ChangelogYml.php index 50bac3fb..c86ac4ae 100644 --- a/src/Wrapper/ChangelogYml.php +++ b/src/Wrapper/ChangelogYml.php @@ -21,6 +21,8 @@ use Horde\Components\Helper\Version; use Horde\HordeYmlFile\ChangelogYmlFile as LibraryChangelogYmlFile; use Horde\HordeYmlFile\InvalidChangelogFileException; +use ArrayObject; +use Stringable; /** * Wrapper for the changelog.yml file. @@ -32,7 +34,7 @@ * @author Jan Schneider * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ -class ChangelogYml extends \ArrayObject implements Wrapper, \Stringable +class ChangelogYml extends ArrayObject implements Wrapper, Stringable { use WrapperTrait; @@ -148,14 +150,21 @@ private function syncToLibrary(): void } // If ArrayObject was modified directly (legacy code path), - // sync changes back to library + // sync changes back to library by recreating the file $currentArray = $this->getArrayCopy(); - // Clear and rebuild from ArrayObject - // Note: This is a bit hacky but maintains backward compatibility - // The library's addVersionEntry will handle sorting - foreach ($currentArray as $version => $entry) { - $this->changelogYmlFile->addVersionEntry($version, $entry); + // Only truncate and recreate if we have entries + // This prevents creating empty files during test teardown + if (!empty($currentArray)) { + // Recreate the file with only the current ArrayObject contents + // This handles deletions (unset) properly + file_put_contents($this->_file, "---\n"); + $this->changelogYmlFile = new LibraryChangelogYmlFile($this->_file); + + // Add all current entries + foreach ($currentArray as $version => $entry) { + $this->changelogYmlFile->addVersionEntry($version, $entry); + } } } @@ -164,6 +173,11 @@ private function syncToLibrary(): void */ public function save(): void { + // Don't create empty changelog files + if (!isset($this->changelogYmlFile) && $this->count() === 0) { + return; + } + // Create library instance if not already created (for new files) if (!isset($this->changelogYmlFile)) { $dir = dirname($this->_file); @@ -196,7 +210,8 @@ public function __toString(): string return strnatcmp($b, $a); }); - $this->syncToLibrary(); + // Don't sync to library here - __toString() should be read-only + // Syncing happens in save() return (string) $this->changelogYmlFile; } } diff --git a/src/Wrapper/Changes.php b/src/Wrapper/Changes.php index 2d00f6ff..d266703e 100644 --- a/src/Wrapper/Changes.php +++ b/src/Wrapper/Changes.php @@ -16,6 +16,9 @@ use Horde\Components\Wrapper; use Horde\Components\WrapperTrait; +use ArrayIterator; +use IteratorAggregate; +use Stringable; /** * Wrapper for the CHANGES file. @@ -25,7 +28,7 @@ * @author Jan Schneider * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ -class Changes implements \IteratorAggregate, Wrapper, \Stringable +class Changes implements IteratorAggregate, Wrapper, Stringable { use WrapperTrait; @@ -58,11 +61,11 @@ public function __construct($docDir) /** * Returns the iterator over the changes. * - * @return \ArrayIterator An iterator. + * @return ArrayIterator An iterator. */ - public function getIterator(): \ArrayIterator + public function getIterator(): ArrayIterator { - return new \ArrayIterator($this->_changes); + return new ArrayIterator($this->_changes); } /** diff --git a/src/Wrapper/ComposerJson.php b/src/Wrapper/ComposerJson.php index b4860a72..5a87aa92 100644 --- a/src/Wrapper/ComposerJson.php +++ b/src/Wrapper/ComposerJson.php @@ -17,6 +17,8 @@ use Horde\Components\Exception; use Horde\Components\Wrapper; use Horde\Components\WrapperTrait; +use ArrayObject; +use Stringable; /** * Wrapper for the composer.json file. @@ -26,7 +28,7 @@ * @author Jan Schneider * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ -class ComposerJson extends \ArrayObject implements Wrapper, \Stringable +class ComposerJson extends ArrayObject implements Wrapper, Stringable { use WrapperTrait; diff --git a/src/Wrapper/HordeYml.php b/src/Wrapper/HordeYml.php index 66029500..c6eb625f 100644 --- a/src/Wrapper/HordeYml.php +++ b/src/Wrapper/HordeYml.php @@ -22,6 +22,8 @@ use Horde\Components\License; use Horde\HordeYmlFile\HordeYmlFile as LibraryHordeYmlFile; use Horde\HordeYmlFile\InvalidHordeYmlFileException; +use ArrayObject; +use Stringable; /** * Wrapper for the .horde.yml file. @@ -33,7 +35,7 @@ * @author Jan Schneider * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ -class HordeYml extends \ArrayObject implements Wrapper, \Stringable +class HordeYml extends ArrayObject implements Wrapper, Stringable { use WrapperTrait; @@ -55,20 +57,29 @@ public function __construct(ComponentDirectory|string $baseDir) try { if ($this->exists()) { $this->hordeYmlFile = new LibraryHordeYmlFile($this->_file); + // Apply graceful defaults for missing fields + $this->hordeYmlFile->applyGracefulUpdates(); } else { // Create empty file for new components - touch($this->_file); - $this->hordeYmlFile = new LibraryHordeYmlFile($this->_file); + // Suppress warning if directory is not writable + @touch($this->_file); + if (file_exists($this->_file)) { + $this->hordeYmlFile = new LibraryHordeYmlFile($this->_file); + // Apply graceful defaults for missing fields + $this->hordeYmlFile->applyGracefulUpdates(); + } } - - // Apply graceful defaults for missing fields - $this->hordeYmlFile->applyGracefulUpdates(); } catch (InvalidHordeYmlFileException $e) { throw new Exception("Failed to load .horde.yml: " . $e->getMessage(), 0, $e); } // Initialize ArrayObject with library data for backward compatibility - parent::__construct($this->hordeYmlFile->toArray()); + // Only if we successfully created the hordeYmlFile + if (isset($this->hordeYmlFile)) { + parent::__construct($this->hordeYmlFile->toArray()); + } else { + parent::__construct([]); + } } /** diff --git a/src/Wrapper/PackageXml.php b/src/Wrapper/PackageXml.php index fdc8cad4..ec7b4297 100644 --- a/src/Wrapper/PackageXml.php +++ b/src/Wrapper/PackageXml.php @@ -17,6 +17,7 @@ use Horde\Components\Exception; use Horde\Components\Wrapper; use Horde\Components\WrapperTrait; +use Horde_Pear_Package_Xml; /** * Wrapper for the package.xml file. @@ -26,7 +27,7 @@ * @author Jan Schneider * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ -class PackageXml extends \Horde_Pear_Package_Xml implements Wrapper +class PackageXml extends Horde_Pear_Package_Xml implements Wrapper { use WrapperTrait; diff --git a/src/WrapperTrait.php b/src/WrapperTrait.php index 9f05e479..260b9684 100644 --- a/src/WrapperTrait.php +++ b/src/WrapperTrait.php @@ -15,6 +15,8 @@ namespace Horde\Components; use Horde\Components\Wrapper; +use Horde_Text_Diff; +use Horde_Text_Diff_Renderer_Unified; /** * Trait for the component file wrappers. @@ -87,7 +89,7 @@ public function exists() */ public function diff(?Wrapper $wrapper = null) { - $renderer = new \Horde_Text_Diff_Renderer_Unified(); + $renderer = new Horde_Text_Diff_Renderer_Unified(); if ($wrapper) { $old = explode("\n", trim($wrapper, "\n")); } elseif ($this->exists()) { @@ -96,7 +98,7 @@ public function diff(?Wrapper $wrapper = null) $old = []; } return $renderer->render( - new \Horde_Text_Diff( + new Horde_Text_Diff( 'auto', [$old, explode("\n", rtrim((string) $this, "\n"))] ) diff --git a/test-broken/StoryTestCase.php b/test-broken/StoryTestCase.php index 99d4987a..78b25f79 100644 --- a/test-broken/StoryTestCase.php +++ b/test-broken/StoryTestCase.php @@ -15,6 +15,9 @@ use Horde\Components\Exception\Pear as ExceptionPear; use Horde\Components\Components; use Horde\Components\Dependencies\Injector; +use DirectoryIterator; +use Horde_Test_Stub_Cli; +use PHPUnit_Extensions_Story_TestCase; /** * Base for story based package testing. @@ -31,7 +34,7 @@ * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 * @coversNothing */ -class StoryTestCase extends \PHPUnit_Extensions_Story_TestCase +class StoryTestCase extends PHPUnit_Extensions_Story_TestCase { public function tearDown() { @@ -653,7 +656,7 @@ public function runThen(&$world, $action, $arguments) break; case 'a package snapshot will be generated at the indicated archive directory': $found = false; - foreach (new \DirectoryIterator($this->_temp_dir) as $file) { + foreach (new DirectoryIterator($this->_temp_dir) as $file) { if (preg_match('/Install-[0-9]+(\.[0-9]+)+([a-z0-9]+)?/', $file->getBasename('.tgz'), $matches)) { $found = true; } @@ -662,7 +665,7 @@ public function runThen(&$world, $action, $arguments) break; case 'a package release will be generated in the current directory': $found = false; - foreach (new \DirectoryIterator($this->_temp_dir) as $file) { + foreach (new DirectoryIterator($this->_temp_dir) as $file) { if (preg_match('/Install-0.0.1/', $file->getBasename('.tgz'), $matches)) { $found = true; } @@ -752,7 +755,7 @@ private function _callComponents(array $parameters, $callback) $parameters['dependencies'] = new Injector(); $parameters['dependencies']->setInstance( 'Horde_Cli', - new \Horde_Test_Stub_Cli(['output' => $stream]) + new Horde_Test_Stub_Cli(['output' => $stream]) ); call_user_func_array($callback, [$parameters]); rewind($stream); diff --git a/test/Auth/AuthenticationFactoryTest.php b/test/Auth/AuthenticationFactoryTest.php index 86a8c5e5..85242291 100644 --- a/test/Auth/AuthenticationFactoryTest.php +++ b/test/Auth/AuthenticationFactoryTest.php @@ -14,6 +14,7 @@ use Psr\Http\Client\ClientInterface; use Psr\Http\Message\RequestFactoryInterface; use Psr\Http\Message\StreamFactoryInterface; +use Exception; /** * Copyright 2026 The Horde Project (http://www.horde.org/) @@ -61,7 +62,7 @@ public function testCreateWithGitHubAppFromConfig(): void 'github.app.id' => '123456', 'github.app.installation_id' => '789012', 'github.app.private_key_path' => $this->testKeyPath, - default => throw new \Exception("Setting not found") + default => throw new Exception("Setting not found") }; }); @@ -128,7 +129,7 @@ public function testCreateWithPatFromConfig(): void $config->method('getSetting')->willReturnCallback(function ($key) { return match ($key) { 'github.token' => 'ghp_config_token_456', - default => throw new \Exception("Setting not found") + default => throw new Exception("Setting not found") }; }); @@ -180,7 +181,7 @@ public function testGitHubAppTakesPrecedenceOverPatConfig(): void 'github.app.installation_id' => '456', 'github.app.private_key_path' => $this->testKeyPath, 'github.token' => 'ghp_should_be_ignored', - default => throw new \Exception("Setting not found") + default => throw new Exception("Setting not found") }; }); @@ -248,7 +249,7 @@ public function testHasGitHubAppAuthReturnsTrueWhenConfigured(): void 'github.app.id' => '123', 'github.app.installation_id' => '456', 'github.app.private_key_path' => $this->testKeyPath, - default => throw new \Exception("Setting not found") + default => throw new Exception("Setting not found") }; }); @@ -288,7 +289,7 @@ public function testHasAuthReturnsTrueForGitHubApp(): void 'github.app.id' => '123', 'github.app.installation_id' => '456', 'github.app.private_key_path' => $this->testKeyPath, - default => throw new \Exception("Setting not found") + default => throw new Exception("Setting not found") }; }); @@ -363,7 +364,7 @@ public function testGetAuthMethodReturnsCorrectDescriptionForGitHubApp(): void 'github.app.id' => '123', 'github.app.installation_id' => '456', 'github.app.private_key_path' => $this->testKeyPath, - default => throw new \Exception("Setting not found") + default => throw new Exception("Setting not found") }; }); diff --git a/test/Auth/GeneratedJwtTest.php b/test/Auth/GeneratedJwtTest.php index 06c256fa..f9eaaee5 100644 --- a/test/Auth/GeneratedJwtTest.php +++ b/test/Auth/GeneratedJwtTest.php @@ -7,6 +7,7 @@ use Horde\Components\Auth\GeneratedJwt; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use ReflectionClass; /** * Copyright 2026 The Horde Project (http://www.horde.org/) @@ -84,7 +85,7 @@ public function testPropertiesAreReadonly(): void { $jwt = new GeneratedJwt('token', time() + 600); - $reflection = new \ReflectionClass($jwt); + $reflection = new ReflectionClass($jwt); $tokenProperty = $reflection->getProperty('token'); $this->assertTrue($tokenProperty->isReadOnly()); diff --git a/test/Auth/GitHubAppConfigTest.php b/test/Auth/GitHubAppConfigTest.php index 1d5f1aac..2c3ace5a 100644 --- a/test/Auth/GitHubAppConfigTest.php +++ b/test/Auth/GitHubAppConfigTest.php @@ -8,6 +8,7 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use ReflectionClass; /** * Copyright 2026 The Horde Project (http://www.horde.org/) @@ -229,7 +230,7 @@ public function testPropertiesAreReadonly(): void { $config = new GitHubAppConfig(123, 456, '/path/key.pem'); - $reflection = new \ReflectionClass($config); + $reflection = new ReflectionClass($config); foreach ($reflection->getProperties() as $property) { $this->assertTrue( diff --git a/test/Auth/PrivateKeyTest.php b/test/Auth/PrivateKeyTest.php index 2587bc18..f403e472 100644 --- a/test/Auth/PrivateKeyTest.php +++ b/test/Auth/PrivateKeyTest.php @@ -8,6 +8,8 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use OpenSSLAsymmetricKey; +use ReflectionClass; /** * Copyright 2026 The Horde Project (http://www.horde.org/) @@ -104,7 +106,7 @@ public function testGetResourceReturnsValidResource(): void // In PHP 8+, openssl_pkey_get_private returns OpenSSLAsymmetricKey object // In PHP 7, it returns a resource $this->assertTrue( - is_resource($resource) || $resource instanceof \OpenSSLAsymmetricKey, + is_resource($resource) || $resource instanceof OpenSSLAsymmetricKey, 'Expected OpenSSL key resource or OpenSSLAsymmetricKey object' ); @@ -119,7 +121,7 @@ public function testContentIsReadonly(): void { $key = PrivateKey::fromString($this->testKeyContent); - $reflection = new \ReflectionClass($key); + $reflection = new ReflectionClass($key); $property = $reflection->getProperty('content'); $this->assertTrue($property->isReadOnly()); diff --git a/test/Auth/Rs256JwtGeneratorTest.php b/test/Auth/Rs256JwtGeneratorTest.php index 0ef50be8..b0f82857 100644 --- a/test/Auth/Rs256JwtGeneratorTest.php +++ b/test/Auth/Rs256JwtGeneratorTest.php @@ -4,7 +4,7 @@ namespace Horde\Components\Test\Auth; -use Horde\Components\Auth\Rs256JwtGenerator; +use Horde\Components\Auth\GitHubJwtGenerator; use Horde\Components\Auth\PrivateKey; use Horde\Components\Auth\GeneratedJwt; use RuntimeException; @@ -21,16 +21,16 @@ * @package Components * @license http://www.horde.org/licenses/lgpl21 LGPL 2.1 */ -#[CoversClass(Rs256JwtGenerator::class)] +#[CoversClass(GitHubJwtGenerator::class)] class Rs256JwtGeneratorTest extends TestCase { - private Rs256JwtGenerator $generator; + private GitHubJwtGenerator $generator; private PrivateKey $privateKey; private string $publicKeyPath; protected function setUp(): void { - $this->generator = new Rs256JwtGenerator(); + $this->generator = new GitHubJwtGenerator(); $privateKeyPath = __DIR__ . '/test-private-key.pem'; $this->publicKeyPath = __DIR__ . '/test-public-key.pem'; $this->privateKey = PrivateKey::fromFile($privateKeyPath); diff --git a/test/Runner/StatusTest.php b/test/Runner/StatusTest.php index c58eb6a8..5bf168fa 100644 --- a/test/Runner/StatusTest.php +++ b/test/Runner/StatusTest.php @@ -19,6 +19,7 @@ use Horde\GithubApiClient\RateLimit; use PHPUnit\Framework\TestCase; use PHPUnit\Framework\Attributes\CoversClass; +use RuntimeException; /** * Copyright 2026 The Horde Project (http://www.horde.org/) @@ -258,7 +259,7 @@ public function testGitHubAppVerifyWithAuthenticationFailure(): void // Simulate authentication failure $strategy->method('authenticate') - ->willThrowException(new \RuntimeException('Authentication failed: Invalid credentials')); + ->willThrowException(new RuntimeException('Authentication failed: Invalid credentials')); // Expect failure warning $output->expects($this->atLeastOnce()) diff --git a/test/Stub/OutputCli.php b/test/Stub/OutputCli.php index 7fe0b082..b12de8dd 100644 --- a/test/Stub/OutputCli.php +++ b/test/Stub/OutputCli.php @@ -16,8 +16,9 @@ namespace Horde\Components\Test\Stub; use Horde\Components\Exception; +use Horde_Cli; -class OutputCli extends \Horde_Cli +class OutputCli extends Horde_Cli { public $messages = []; diff --git a/test/TestCase.php b/test/TestCase.php index 1cdb464c..2618483a 100644 --- a/test/TestCase.php +++ b/test/TestCase.php @@ -18,6 +18,9 @@ use Horde\Components\Dependencies\Injector; use Horde\Components\Release\Notes as ReleaseNotes; use Horde\Components\Test\Stub\Output; +use DirectoryIterator; +use Horde_Test_Stub_Cli; +use Horde_Util; /** * Test base. @@ -93,7 +96,7 @@ protected function getReleaseTasks() protected function getTemporaryDirectory() { - return \Horde_Util::createTempDir(); + return Horde_Util::createTempDir(); } protected function getHelp() @@ -126,7 +129,7 @@ private function _callComponents(array $parameters, $callback) $parameters['dependencies'] = new Injector(); $parameters['dependencies']->setInstance( 'Horde_Cli', - new \Horde_Test_Stub_Cli(['output' => $stream]) + new Horde_Test_Stub_Cli(['output' => $stream]) ); call_user_func_array($callback, [$parameters]); rewind($stream); @@ -154,7 +157,7 @@ protected function fileRegexpPresent($regex, $dir) { $files = []; $found = false; - foreach (new \DirectoryIterator($dir) as $file) { + foreach (new DirectoryIterator($dir) as $file) { if (preg_match($regex, $file->getBasename('.tgz'))) { $found = true; } diff --git a/test/Unit/Ci/Run/RunCommandTest.php b/test/Unit/Ci/Run/RunCommandTest.php index a376e97a..18237e5d 100644 --- a/test/Unit/Ci/Run/RunCommandTest.php +++ b/test/Unit/Ci/Run/RunCommandTest.php @@ -46,7 +46,7 @@ protected function setUp(): void // Create temp directory for test lanes $this->tempDir = sys_get_temp_dir() . '/horde-ci-run-test-' . uniqid(); - mkdir($this->tempDir, 0755, true); + mkdir($this->tempDir, 0o755, true); // Create RunCommand $this->runCommand = new RunCommand( @@ -72,17 +72,19 @@ public function testExecutesLaneScript(): void $componentDir = $laneDir . '/TestComponent'; $buildDir = $componentDir . '/build'; - mkdir($buildDir, 0755, true); + mkdir($buildDir, 0o755, true); // Create a test lane script that succeeds $scriptPath = $laneDir . '/run-lane.sh'; - file_put_contents($scriptPath, <<<'BASH' -#!/bin/bash -echo "Test script running" -exit 0 -BASH + file_put_contents( + $scriptPath, + <<<'BASH' + #!/bin/bash + echo "Test script running" + exit 0 + BASH ); - chmod($scriptPath, 0755); + chmod($scriptPath, 0o755); // Mock collector expectations $this->collector @@ -106,7 +108,7 @@ public function testHandlesMissingLaneScript(): void $laneDir = $this->tempDir . '/lanes/php8.4-dev'; $componentDir = $laneDir . '/TestComponent'; - mkdir($componentDir, 0755, true); + mkdir($componentDir, 0o755, true); // No script created @@ -132,12 +134,12 @@ public function testHandlesNonExecutableLaneScript(): void $laneDir = $this->tempDir . '/lanes/php8.4-dev'; $componentDir = $laneDir . '/TestComponent'; - mkdir($componentDir, 0755, true); + mkdir($componentDir, 0o755, true); // Create script but don't make it executable $scriptPath = $laneDir . '/run-lane.sh'; file_put_contents($scriptPath, "#!/bin/bash\necho test\n"); - chmod($scriptPath, 0644); // Not executable + chmod($scriptPath, 0o644); // Not executable // Mock output to expect error message $this->output @@ -162,20 +164,22 @@ public function testCapturesLaneScriptOutput(): void $componentDir = $laneDir . '/TestComponent'; $buildDir = $componentDir . '/build'; - mkdir($buildDir, 0755, true); + mkdir($buildDir, 0o755, true); // Create script with specific output $scriptPath = $laneDir . '/run-lane.sh'; - file_put_contents($scriptPath, <<<'BASH' -#!/bin/bash -echo "=== Running PHPUnit ===" -echo "Tests: 10, Assertions: 25" -echo "=== Running PHPStan ===" -echo "No errors found" -exit 0 -BASH + file_put_contents( + $scriptPath, + <<<'BASH' + #!/bin/bash + echo "=== Running PHPUnit ===" + echo "Tests: 10, Assertions: 25" + echo "=== Running PHPStan ===" + echo "No errors found" + exit 0 + BASH ); - chmod($scriptPath, 0755); + chmod($scriptPath, 0o755); // Mock output to capture script output $capturedOutput = []; @@ -205,17 +209,19 @@ public function testPropagatesLaneScriptExitCode(): void $componentDir = $laneDir . '/TestComponent'; $buildDir = $componentDir . '/build'; - mkdir($buildDir, 0755, true); + mkdir($buildDir, 0o755, true); // Create script that exits with error $scriptPath = $laneDir . '/run-lane.sh'; - file_put_contents($scriptPath, <<<'BASH' -#!/bin/bash -echo "Tests failed" -exit 42 -BASH + file_put_contents( + $scriptPath, + <<<'BASH' + #!/bin/bash + echo "Tests failed" + exit 42 + BASH ); - chmod($scriptPath, 0755); + chmod($scriptPath, 0o755); // Mock output to expect warning about exit code $this->output @@ -248,12 +254,12 @@ public function testDiscoversMultipleLanes(): void $componentDir = $laneDir . '/TestComponent'; $buildDir = $componentDir . '/build'; - mkdir($buildDir, 0755, true); + mkdir($buildDir, 0o755, true); // Create passing script $scriptPath = $laneDir . '/run-lane.sh'; file_put_contents($scriptPath, "#!/bin/bash\nexit 0\n"); - chmod($scriptPath, 0755); + chmod($scriptPath, 0o755); } // Mock output to capture lane info messages @@ -281,16 +287,16 @@ public function testSkipsInvalidLaneDirectories(): void // Create valid lane $validLaneDir = $this->tempDir . '/lanes/php8.4-dev'; $componentDir = $validLaneDir . '/TestComponent'; - mkdir($componentDir, 0755, true); + mkdir($componentDir, 0o755, true); $scriptPath = $validLaneDir . '/run-lane.sh'; file_put_contents($scriptPath, "#!/bin/bash\nexit 0\n"); - chmod($scriptPath, 0755); + chmod($scriptPath, 0o755); // Create invalid lane names that should be skipped - mkdir($this->tempDir . '/lanes/invalid-name', 0755, true); - mkdir($this->tempDir . '/lanes/php-noversion', 0755, true); - mkdir($this->tempDir . '/lanes/php8.4', 0755, true); // Missing stability + mkdir($this->tempDir . '/lanes/invalid-name', 0o755, true); + mkdir($this->tempDir . '/lanes/php-noversion', 0o755, true); + mkdir($this->tempDir . '/lanes/php8.4', 0o755, true); // Missing stability // Mock collector $this->collector->method('displaySummary'); @@ -322,12 +328,12 @@ public function testAggregatesResultsFromMultipleLanes(): void $componentDir = $laneDir . '/TestComponent'; $buildDir = $componentDir . '/build'; - mkdir($buildDir, 0755, true); + mkdir($buildDir, 0o755, true); // Create script $scriptPath = $laneDir . '/run-lane.sh'; file_put_contents($scriptPath, "#!/bin/bash\nexit 0\n"); - chmod($scriptPath, 0755); + chmod($scriptPath, 0o755); // Create mock result files file_put_contents($buildDir . '/phpunit-results-summary.json', '{"tests":10,"passed":true}'); @@ -353,12 +359,12 @@ public function testReportsSkippedWhenResultFileMissing(): void $componentDir = $laneDir . '/TestComponent'; $buildDir = $componentDir . '/build'; - mkdir($buildDir, 0755, true); + mkdir($buildDir, 0o755, true); // Create script $scriptPath = $laneDir . '/run-lane.sh'; file_put_contents($scriptPath, "#!/bin/bash\nexit 0\n"); - chmod($scriptPath, 0755); + chmod($scriptPath, 0o755); // No result files created diff --git a/test/Unit/Ci/Setup/LaneScriptGeneratorJsonTest.php b/test/Unit/Ci/Setup/LaneScriptGeneratorJsonTest.php index 35efd688..282b369b 100644 --- a/test/Unit/Ci/Setup/LaneScriptGeneratorJsonTest.php +++ b/test/Unit/Ci/Setup/LaneScriptGeneratorJsonTest.php @@ -41,7 +41,7 @@ protected function setUp(): void $this->output = $this->createMock(Output::class); $this->generator = new LaneScriptGenerator($this->output); $this->tempDir = sys_get_temp_dir() . '/lane-script-test-' . uniqid(); - mkdir($this->tempDir, 0755, true); + mkdir($this->tempDir, 0o755, true); } protected function tearDown(): void @@ -62,30 +62,25 @@ public function testScriptGeneratesPhpunitJsonSummary(): void 'component_dir' => $this->tempDir . '/component', 'tools_dir' => $this->tempDir . '/tools', 'build_dir' => $this->tempDir . '/component/build', + 'components_path' => '/usr/bin/horde-components', ]; $this->generator->generate($scriptPath, $config); $script = file_get_contents($scriptPath); - // Should generate JSON summary file for PHPUnit + // Should mention JSON summary file for PHPUnit $this->assertStringContainsString( 'phpunit-results-summary.json', $script, - 'Script should write PHPUnit JSON summary' + 'Script should mention PHPUnit JSON summary location' ); - // Should extract test statistics from PHPUnit output + // Should call horde-components qc unit which generates JSON $this->assertStringContainsString( - '"success"', + 'qc unit', $script, - 'JSON should include success field' - ); - - $this->assertStringContainsString( - '"exit_code"', - $script, - 'JSON should include exit_code field' + 'Script should call qc unit command which generates JSON results' ); } @@ -100,24 +95,25 @@ public function testScriptParsesPhpunitXmlForStatistics(): void 'component_dir' => $this->tempDir . '/component', 'tools_dir' => $this->tempDir . '/tools', 'build_dir' => $this->tempDir . '/component/build', + 'components_path' => '/usr/bin/horde-components', ]; $this->generator->generate($scriptPath, $config); $script = file_get_contents($scriptPath); - // Should parse JUnit XML for test counts + // Should call qc unit which generates JSON with statistics $this->assertStringContainsString( - 'phpunit-results.xml', + 'qc unit', $script, - 'Script should read PHPUnit XML results' + 'Script should call qc unit which generates test statistics' ); - // Should extract statistics (tests, failures, errors, etc.) + // Should mention the JSON output location $this->assertStringContainsString( - '"statistics"', + 'phpunit-results-summary.json', $script, - 'JSON should include statistics field' + 'Script should mention PHPUnit JSON summary location' ); } @@ -132,17 +128,18 @@ public function testScriptWritesPhpstanJsonSummary(): void 'component_dir' => $this->tempDir . '/component', 'tools_dir' => $this->tempDir . '/tools', 'build_dir' => $this->tempDir . '/component/build', + 'components_path' => '/usr/bin/horde-components', ]; $this->generator->generate($scriptPath, $config); $script = file_get_contents($scriptPath); - // PHPStan already outputs JSON, but we need to wrap it with success/exit_code + // PHPStan JSON is generated by qc phpstan command $this->assertStringContainsString( 'phpstan-results.json', $script, - 'Script should handle PHPStan JSON' + 'Script should mention PHPStan JSON location' ); } @@ -157,17 +154,18 @@ public function testScriptWritesPhpCsFixerJsonSummary(): void 'component_dir' => $this->tempDir . '/component', 'tools_dir' => $this->tempDir . '/tools', 'build_dir' => $this->tempDir . '/component/build', + 'components_path' => '/usr/bin/horde-components', ]; $this->generator->generate($scriptPath, $config); $script = file_get_contents($scriptPath); - // PHP-CS-Fixer outputs JSON already, similar to PHPStan + // PHP-CS-Fixer JSON is generated by qc phpcsfixer command $this->assertStringContainsString( 'php-cs-fixer-results.json', $script, - 'Script should handle PHP-CS-Fixer JSON' + 'Script should mention PHP-CS-Fixer JSON location' ); } @@ -182,21 +180,25 @@ public function testJsonFormatMatchesResultCollectorExpectations(): void 'component_dir' => $this->tempDir . '/component', 'tools_dir' => $this->tempDir . '/tools', 'build_dir' => $this->tempDir . '/component/build', + 'components_path' => '/usr/bin/horde-components', ]; $this->generator->generate($scriptPath, $config); $script = file_get_contents($scriptPath); - // ResultCollector expects: success, exit_code, statistics, version - $expectedFields = ['success', 'exit_code', 'statistics']; + // ResultCollector expects JSON files to be created by qc commands + // The script should call the qc commands that generate these JSON files + $this->assertStringContainsString( + 'qc unit', + $script, + 'Script should call qc unit which generates JSON results' + ); - foreach ($expectedFields as $field) { - $this->assertStringContainsString( - "\"$field\"", - $script, - "JSON should include required field: $field" - ); - } + $this->assertStringContainsString( + 'qc phpstan', + $script, + 'Script should call qc phpstan which generates JSON results' + ); } } diff --git a/test/Unit/Ci/Setup/LaneScriptGeneratorTest.php b/test/Unit/Ci/Setup/LaneScriptGeneratorTest.php index 4fcdd5f4..7de433af 100644 --- a/test/Unit/Ci/Setup/LaneScriptGeneratorTest.php +++ b/test/Unit/Ci/Setup/LaneScriptGeneratorTest.php @@ -43,7 +43,7 @@ protected function setUp(): void // Create temp directory for test scripts $this->tempDir = sys_get_temp_dir() . '/horde-ci-test-' . uniqid(); - mkdir($this->tempDir, 0755, true); + mkdir($this->tempDir, 0o755, true); } protected function tearDown(): void @@ -129,7 +129,7 @@ public function testScriptIsExecutable(): void // Check actual permissions $perms = fileperms($scriptPath); - $this->assertTrue(($perms & 0100) !== 0, 'Owner should have execute permission'); + $this->assertTrue(($perms & 0o100) !== 0, 'Owner should have execute permission'); } public function testScriptContainsCorrectShebang(): void @@ -188,6 +188,7 @@ public function testScriptUsesCorrectToolPaths(): void 'component_dir' => '/tmp/horde-ci/lanes/php8.4-dev/Http', 'tools_dir' => '/custom/tools/path', 'build_dir' => '/tmp/horde-ci/lanes/php8.4-dev/Http/build', + 'components_path' => '/usr/bin/horde-components', ]; $this->generator->generate($scriptPath, $config); @@ -195,8 +196,7 @@ public function testScriptUsesCorrectToolPaths(): void $content = file_get_contents($scriptPath); $this->assertStringContainsString('TOOLS_DIR="/custom/tools/path"', $content, 'Should set correct tools directory'); - $this->assertStringContainsString('$TOOLS_DIR/phpunit-', $content, 'Should use tools directory for PHPUnit'); - $this->assertStringContainsString('$TOOLS_DIR/phpstan.phar', $content, 'Should use tools directory for PHPStan'); + $this->assertStringContainsString('--tools-dir="$TOOLS_DIR"', $content, 'Should pass tools directory to qc commands'); } public function testScriptCreatesBuilDirectory(): void diff --git a/test/Unit/Ci/Setup/SetupCommandTest.php b/test/Unit/Ci/Setup/SetupCommandTest.php index 8cb0b9f2..4bfb3bf5 100644 --- a/test/Unit/Ci/Setup/SetupCommandTest.php +++ b/test/Unit/Ci/Setup/SetupCommandTest.php @@ -63,24 +63,26 @@ protected function setUp(): void // Create temp directory for test component $this->tempDir = sys_get_temp_dir() . '/horde-ci-setup-test-' . uniqid(); - mkdir($this->tempDir, 0755, true); + mkdir($this->tempDir, 0o755, true); $this->testComponentPath = $this->tempDir . '/component'; - mkdir($this->testComponentPath, 0755, true); + mkdir($this->testComponentPath, 0o755, true); // Create minimal .horde.yml - file_put_contents($this->testComponentPath . '/.horde.yml', <<testComponentPath . '/.horde.yml', + << '8.2', 'component_stability' => 'stable', 'local_components_path' => '/usr/bin/horde-components', + 'components_path' => '/usr/bin/horde-components', ]); // Mock PHP installer to return binary path @@ -200,11 +203,14 @@ public function testLaneScriptGenerationFailureIsReported(): void 'min_php_version' => '8.2', 'component_stability' => 'stable', 'local_components_path' => '/usr/bin/horde-components', + 'components_path' => '/usr/bin/horde-components', ]); // Setup mocks for successful setup until lane script generation $this->phpInstaller->method('install'); - $this->phpInstaller->method('getPhpBinary')->willReturnCallback(function ($v) { return "/usr/bin/php{$v}"; }); + $this->phpInstaller->method('getPhpBinary')->willReturnCallback(function ($v) { + return "/usr/bin/php{$v}"; + }); $this->extensionInstaller->method('detectExtensions')->willReturn([]); $this->extensionInstaller->method('install'); $this->laneCopier->method('copyToLanes'); @@ -247,11 +253,14 @@ public function testLaneScriptConfigContainsCorrectValues(): void 'min_php_version' => '8.2', 'component_stability' => 'stable', 'local_components_path' => '/usr/bin/horde-components', + 'components_path' => '/usr/bin/horde-components', ]); // Setup mocks $this->phpInstaller->method('install'); - $this->phpInstaller->method('getPhpBinary')->willReturnCallback(function ($v) { return "/usr/bin/php{$v}"; }); + $this->phpInstaller->method('getPhpBinary')->willReturnCallback(function ($v) { + return "/usr/bin/php{$v}"; + }); $this->extensionInstaller->method('detectExtensions')->willReturn([]); $this->extensionInstaller->method('install'); $this->laneCopier->method('copyToLanes'); @@ -272,27 +281,23 @@ public function testLaneScriptConfigContainsCorrectValues(): void // Execute setup $this->setupCommand->execute($config); - // Debug: CiConfig defaults to alpha for stability, not stable - // The actual stability used is from componentStability which defaults to 'alpha' - // Verify we got configs for lanes $this->assertGreaterThanOrEqual(2, count($capturedConfigs), 'Should generate scripts for at least 2 lanes'); - // Find configs for 8.3-dev and 8.3-alpha (not stable, because default is alpha) + // Find configs for 8.3-dev and 8.3-stable (component_stability is set to 'stable') $devLaneConfig = null; - $alphaLaneConfig = null; + $stableLaneConfig = null; foreach ($capturedConfigs as $cfg) { if ($cfg['php_version'] === '8.3' && $cfg['stability'] === 'dev') { $devLaneConfig = $cfg; } - // Note: CiConfig defaults component_stability to 'alpha' - if ($cfg['php_version'] === '8.3' && $cfg['stability'] === 'alpha') { - $alphaLaneConfig = $cfg; + if ($cfg['php_version'] === '8.3' && $cfg['stability'] === 'stable') { + $stableLaneConfig = $cfg; } } $this->assertNotNull($devLaneConfig, 'Should have php8.3-dev lane config'); - $this->assertNotNull($alphaLaneConfig, 'Should have php8.3-alpha lane config'); + $this->assertNotNull($stableLaneConfig, 'Should have php8.3-stable lane config'); // Verify dev lane config $this->assertSame('php8.3-dev', $devLaneConfig['lane_name']); @@ -303,10 +308,10 @@ public function testLaneScriptConfigContainsCorrectValues(): void $this->assertSame('/tmp/test-ci/tools', $devLaneConfig['tools_dir']); $this->assertStringContainsString('/build', $devLaneConfig['build_dir']); - // Verify alpha lane config - $this->assertSame('php8.3-alpha', $alphaLaneConfig['lane_name']); - $this->assertSame('8.3', $alphaLaneConfig['php_version']); - $this->assertSame('alpha', $alphaLaneConfig['stability']); + // Verify stable lane config + $this->assertSame('php8.3-stable', $stableLaneConfig['lane_name']); + $this->assertSame('8.3', $stableLaneConfig['php_version']); + $this->assertSame('stable', $stableLaneConfig['stability']); } public function testLaneScriptsGeneratedAfterComposerNotBefore(): void @@ -320,6 +325,7 @@ public function testLaneScriptsGeneratedAfterComposerNotBefore(): void 'min_php_version' => '8.2', 'component_stability' => 'stable', 'local_components_path' => '/usr/bin/horde-components', + 'components_path' => '/usr/bin/horde-components', ]); $callOrder = []; @@ -329,7 +335,9 @@ public function testLaneScriptsGeneratedAfterComposerNotBefore(): void $callOrder[] = 'php_install'; return true; }); - $this->phpInstaller->method('getPhpBinary')->willReturnCallback(function ($v) { return "/usr/bin/php{$v}"; }); + $this->phpInstaller->method('getPhpBinary')->willReturnCallback(function ($v) { + return "/usr/bin/php{$v}"; + }); $this->extensionInstaller->method('detectExtensions')->willReturn([]); $this->extensionInstaller->method('install'); $this->laneCopier->method('copyToLanes'); @@ -373,11 +381,14 @@ public function testNoLaneScriptsGeneratedIfComposerFails(): void 'min_php_version' => '8.2', 'component_stability' => 'stable', 'local_components_path' => '/usr/bin/horde-components', + 'components_path' => '/usr/bin/horde-components', ]); // Setup mocks $this->phpInstaller->method('install'); - $this->phpInstaller->method('getPhpBinary')->willReturnCallback(function ($v) { return "/usr/bin/php{$v}"; }); + $this->phpInstaller->method('getPhpBinary')->willReturnCallback(function ($v) { + return "/usr/bin/php{$v}"; + }); $this->extensionInstaller->method('detectExtensions')->willReturn([]); $this->extensionInstaller->method('install'); $this->laneCopier->method('copyToLanes'); diff --git a/test/Unit/Ci/Template/TemplateRendererTest.php b/test/Unit/Ci/Template/TemplateRendererTest.php index 252de7f0..8d120801 100644 --- a/test/Unit/Ci/Template/TemplateRendererTest.php +++ b/test/Unit/Ci/Template/TemplateRendererTest.php @@ -38,7 +38,7 @@ protected function setUp(): void { parent::setUp(); $this->tempDir = sys_get_temp_dir() . '/horde-test-' . uniqid(); - mkdir($this->tempDir, 0755, true); + mkdir($this->tempDir, 0o755, true); } protected function tearDown(): void @@ -113,10 +113,10 @@ public function testThrowsExceptionForUnreplacedVariable(): void public function testReplacesMultipleVariables(): void { $templateContent = <<<'TEMPLATE' -Component: {{COMPONENT}} -Version: {{VERSION}} -Path: {{PATH}} -TEMPLATE; + Component: {{COMPONENT}} + Version: {{VERSION}} + Path: {{PATH}} + TEMPLATE; file_put_contents($this->tempDir . '/multi.template', $templateContent); $renderer = new TemplateRenderer($this->tempDir); diff --git a/test/Unit/Components/Helper/DocsOriginTest.php b/test/Unit/Components/Helper/DocsOriginTest.php index bc956b19..dba04713 100644 --- a/test/Unit/Components/Helper/DocsOriginTest.php +++ b/test/Unit/Components/Helper/DocsOriginTest.php @@ -14,6 +14,10 @@ use Horde\Components\Helper\DocsOrigin as HelperDocsOrigin; use Horde\Components\Test\TestCase; +use Horde_Http_Client; +use Horde_Http_Request_Mock; +use Horde_Http_Response_Mock; +use Horde_Support_StringStream; /** * Test the document fetching helper. @@ -72,11 +76,11 @@ public function testMultiple() private function _getClient() { $response = 'REMOTE'; - $body = new \Horde_Support_StringStream($response); - $response = new \Horde_Http_Response_Mock('', $body->fopen()); + $body = new Horde_Support_StringStream($response); + $response = new Horde_Http_Response_Mock('', $body->fopen()); $response->code = 200; - $request = new \Horde_Http_Request_Mock(); + $request = new Horde_Http_Request_Mock(); $request->setResponse($response); - return new \Horde_Http_Client(['request' => $request]); + return new Horde_Http_Client(['request' => $request]); } } diff --git a/test/Unit/Components/Release/Task/ChangelogTest.php b/test/Unit/Components/Release/Task/ChangelogTest.php index c0f41763..6c4a99ec 100644 --- a/test/Unit/Components/Release/Task/ChangelogTest.php +++ b/test/Unit/Components/Release/Task/ChangelogTest.php @@ -101,8 +101,7 @@ private function _getValidPackage() $package = $this->getMockBuilder('Horde\Components\Component\Source') ->disableOriginalConstructor() ->getMock(); - $package->expects($this->any()) - ->method('hasLocalPackageXml') + $package->method('hasLocalPackageXml') ->willReturn(true); return $package; } diff --git a/test/Unit/Components/Release/Task/NextVersionTest.php b/test/Unit/Components/Release/Task/NextVersionTest.php index 16e58858..ecd9b3c7 100644 --- a/test/Unit/Components/Release/Task/NextVersionTest.php +++ b/test/Unit/Components/Release/Task/NextVersionTest.php @@ -182,19 +182,22 @@ public function testPretend() ), ] ); + $output = $this->_output->getOutput(); + + // Check the first message (non-git command) $this->assertEquals( - [ - 'Would add next version "5.0.0-git" with the initial note "" to .horde.yml, package.xml, composer.json, doc/CHANGES, lib/Application.php, doc/changelog.yml now.', - 'Would run "git add .horde.yml" now.', - 'Would run "git add package.xml" now.', - 'Would run "git add composer.json" now.', - 'Would run "git add doc/CHANGES" now.', - 'Would run "git add lib/Application.php" now.', - 'Would run "git add doc/changelog.yml" now.', - "Would run \"git commit -m \"chore: set development mode to 5.0.0\n\nPrepare Horde for next development cycle\"\" now.", - ], - $this->_output->getOutput() + 'Would add next version "5.0.0-git" with the initial note "" to .horde.yml, package.xml, composer.json, doc/CHANGES, lib/Application.php, doc/changelog.yml now.', + $output[0] ); + + // Check git commands (format changed to include colon and working dir) + $this->assertStringStartsWith('Would run: "git add .horde.yml" (in ', $output[1]); + $this->assertStringStartsWith('Would run: "git add package.xml" (in ', $output[2]); + $this->assertStringStartsWith('Would run: "git add composer.json" (in ', $output[3]); + $this->assertStringStartsWith('Would run: "git add doc/CHANGES" (in ', $output[4]); + $this->assertStringStartsWith('Would run: "git add lib/Application.php" (in ', $output[5]); + $this->assertStringStartsWith('Would run: "git add doc/changelog.yml" (in ', $output[6]); + $this->assertStringStartsWith('Would run: "git commit -m "chore: set development mode to 5.0.0', $output[7]); } public function testPretendWithoutVersion() @@ -214,21 +217,22 @@ public function testPretendWithoutVersion() ), ] ); - $this->assertEquals( - [ - 'Would add next version "5.0.1" with the initial note "" to .horde.yml, package.xml, composer.json, doc/CHANGES, lib/Application.php, doc/changelog.yml now.', - 'Would run "git add .horde.yml" now.', - 'Would run "git add package.xml" now.', - 'Would run "git add composer.json" now.', - 'Would run "git add doc/CHANGES" now.', - 'Would run "git add lib/Application.php" now.', - 'Would run "git add doc/changelog.yml" now.', - 'Would run "git commit -m "chore: set development mode to 5.0.1 + $output = $this->_output->getOutput(); -Prepare Horde for next development cycle"" now.', - ], - $this->_output->getOutput() + // Check the first message (non-git command) + $this->assertEquals( + 'Would add next version "5.0.1" with the initial note "" to .horde.yml, package.xml, composer.json, doc/CHANGES, lib/Application.php, doc/changelog.yml now.', + $output[0] ); + + // Check git commands (format changed to include colon and working dir) + $this->assertStringStartsWith('Would run: "git add .horde.yml" (in ', $output[1]); + $this->assertStringStartsWith('Would run: "git add package.xml" (in ', $output[2]); + $this->assertStringStartsWith('Would run: "git add composer.json" (in ', $output[3]); + $this->assertStringStartsWith('Would run: "git add doc/CHANGES" (in ', $output[4]); + $this->assertStringStartsWith('Would run: "git add lib/Application.php" (in ', $output[5]); + $this->assertStringStartsWith('Would run: "git add doc/changelog.yml" (in ', $output[6]); + $this->assertStringStartsWith('Would run: "git commit -m "chore: set development mode to 5.0.1', $output[7]); } public function testPretendAlphaWithoutVersion() diff --git a/test/Unit/Components/Release/Task/PackageTest.php b/test/Unit/Components/Release/Task/PackageTest.php index 2f7abbd8..1569f617 100644 --- a/test/Unit/Components/Release/Task/PackageTest.php +++ b/test/Unit/Components/Release/Task/PackageTest.php @@ -77,9 +77,6 @@ public function testRunTaskWithoutUpload() public function testPretend() { $package = $this->_getPackage(); - $package->expects($this->any()) - ->method('getName') - ->willReturn('NAME'); $this->getReleaseTasks()->run( ['Package'], $package, @@ -90,14 +87,14 @@ public function testPretend() 'upload' => true, ] ); - $this->assertEquals( - [ - 'Would package NAME now.', - 'Would run "scp [PATH TO RESULTING]/[PACKAGE.TGZ - PRETEND MODE] pear.horde.org:~/" now.', - 'Would run "ssh pear.horde.org "umask 0002 && pirum add B ~/[PACKAGE.TGZ - PRETEND MODE] && rm [PACKAGE.TGZ - PRETEND MODE]"" now.', - ], - $this->_output->getOutput() - ); + $output = $this->_output->getOutput(); + + // Check that package message is present + $this->assertEquals('Would package NAME now.', $output[0]); + + // Check git commands (format changed to include colon, removed "now.") + $this->assertStringStartsWith('Would run: "scp [PATH TO RESULTING]/[PACKAGE.TGZ - PRETEND MODE] pear.horde.org:~/"', $output[1]); + $this->assertStringStartsWith('Would run: "ssh pear.horde.org "umask 0002 && pirum add B ~/[PACKAGE.TGZ - PRETEND MODE] && rm [PACKAGE.TGZ - PRETEND MODE]""', $output[2]); } private function _getPackage() @@ -105,12 +102,12 @@ private function _getPackage() $package = $this->getMockBuilder('Horde\Components\Component\Source') ->disableOriginalConstructor() ->getMock(); - $package->expects($this->any()) - ->method('getState') + $package->method('getState') ->willReturn('stable'); - $package->expects($this->any()) - ->method('getVersion') + $package->method('getVersion') ->willReturn('1.0.0'); + $package->method('getName') + ->willReturn('NAME'); return $package; } } diff --git a/test/Unit/Components/Release/Task/TimestampTest.php b/test/Unit/Components/Release/Task/TimestampTest.php index 53fe1783..1888187c 100644 --- a/test/Unit/Components/Release/Task/TimestampTest.php +++ b/test/Unit/Components/Release/Task/TimestampTest.php @@ -93,12 +93,10 @@ public function testPretend() private function _getValidPackage() { $wrapper = $this->getMockBuilder(ChangelogYml::class)->disableOriginalConstructor()->getMock(); - $wrapper->expects($this->any()) - ->method('exists') + $wrapper->method('exists') ->willReturn(true); $package = $this->getMockBuilder(SourceComponent::class)->disableOriginalConstructor()->getMock(); - $package->expects($this->any()) - ->method('getWrapper') + $package->method('getWrapper') ->willReturn($wrapper); return $package; } diff --git a/test/Unit/Components/Wrapper/ChangelogYmlTest.php b/test/Unit/Components/Wrapper/ChangelogYmlTest.php index ac632c17..137a64d9 100644 --- a/test/Unit/Components/Wrapper/ChangelogYmlTest.php +++ b/test/Unit/Components/Wrapper/ChangelogYmlTest.php @@ -17,6 +17,7 @@ use Horde\Components\Test\TestCase; use Horde\Components\Wrapper\ChangelogYml as WrapperChangelogYml; +use Horde_Util; /** * Tests the changelog.yml wrapper. @@ -112,7 +113,7 @@ public function testGetFileName() public function testChangeProperty() { - $dir = \Horde_Util::createTempDir(); + $dir = Horde_Util::createTempDir(); copy( __DIR__ . '/../../../fixtures/deps/doc/Horde/Deps/changelog.yml', $dir . '/changelog.yml' @@ -128,7 +129,7 @@ public function testChangeProperty() public function testAddEntry() { - $dir = \Horde_Util::createTempDir(); + $dir = Horde_Util::createTempDir(); copy( __DIR__ . '/../../../fixtures/deps/doc/Horde/Deps/changelog.yml', $dir . '/changelog.yml' @@ -145,7 +146,7 @@ public function testAddEntry() public function testChangeKey() { - $dir = \Horde_Util::createTempDir(); + $dir = Horde_Util::createTempDir(); copy( __DIR__ . '/../../../fixtures/deps/doc/Horde/Deps/changelog.yml', $dir . '/changelog.yml' diff --git a/test/Unit/OutputTest.php b/test/Unit/OutputTest.php index c7e69fc7..e3c7490a 100644 --- a/test/Unit/OutputTest.php +++ b/test/Unit/OutputTest.php @@ -20,6 +20,7 @@ use Horde\Components\Output; use Horde\Components\Output\Presenter; use Horde_Cli; +use Exception; /** * Test the Output class. @@ -198,7 +199,7 @@ public function testFailDelegatesToCliFatal(): void try { $output->fail('Fatal error'); - } catch (\Exception $e) { + } catch (Exception $e) { // fatal() might throw, that's fine } } diff --git a/test/fixtures/deps/changelog.yml b/test/fixtures/deps/changelog.yml deleted file mode 100644 index ed97d539..00000000 --- a/test/fixtures/deps/changelog.yml +++ /dev/null @@ -1 +0,0 @@ ---- diff --git a/test/fixtures/simple/composer.json b/test/fixtures/simple/composer.json index 6386231c..bfa50d29 100644 --- a/test/fixtures/simple/composer.json +++ b/test/fixtures/simple/composer.json @@ -11,7 +11,7 @@ "role": "lead" } ], - "time": "2026-03-04", + "time": "2026-03-06", "repositories": [], "require": { "horde/horde-installer-plugin": "^3 || ^2", @@ -29,10 +29,5 @@ "allow-plugins": { "horde/horde-installer-plugin": true } - }, - "extra": { - "branch-alias": { - "dev-FRAMEWORK_6_0": "0.x-dev" - } } } \ No newline at end of file diff --git a/test/fixtures/simple/doc/changelog.yml b/test/fixtures/simple/doc/changelog.yml index 84990f71..588075d9 100644 --- a/test/fixtures/simple/doc/changelog.yml +++ b/test/fixtures/simple/doc/changelog.yml @@ -9,4 +9,4 @@ identifier: LGPL-2.1 uri: http://www.horde.org/licenses/lgpl21 notes: | - * Initial release \ No newline at end of file + * Initial release diff --git a/test/unit/Helper/ShellTest.php b/test/unit/Helper/ShellTest.php index 45347c0b..d90135dc 100644 --- a/test/unit/Helper/ShellTest.php +++ b/test/unit/Helper/ShellTest.php @@ -64,7 +64,11 @@ public function testExecWithNonZeroExitCode(): void public function testSystemReturnsString(): void { $shell = new Shell(); + + // Capture stdout to prevent risky test warning + ob_start(); $result = $shell->system('echo "test"'); + ob_end_clean(); $this->assertIsString($result); $this->assertStringContainsString('test', $result); @@ -74,7 +78,11 @@ public function testSystemInWorkingDirectory(): void { $shell = new Shell(); $tmpDir = sys_get_temp_dir(); + + // Capture stdout to prevent risky test warning + ob_start(); $result = $shell->system('pwd', $tmpDir); + ob_end_clean(); $this->assertStringContainsString($tmpDir, $result); } @@ -167,7 +175,10 @@ public function testStaticRunMethodWithWorkingDir(): void public function testStaticRunSystemMethod(): void { + // Capture stdout to prevent risky test warning + ob_start(); $result = Shell::runSystem('echo "test"'); + ob_end_clean(); $this->assertIsString($result); $this->assertStringContainsString('test', $result); @@ -210,7 +221,11 @@ public function testSystemPreservesCurrentDirectory(): void $tmpDir = sys_get_temp_dir(); $shell = new Shell(); + + // Capture stdout to prevent risky test warning + ob_start(); $shell->system('echo test', $tmpDir); + ob_end_clean(); $this->assertSame($originalDir, getcwd()); }