diff --git a/src/Application.php b/src/Application.php index b4fa578..cdec268 100644 --- a/src/Application.php +++ b/src/Application.php @@ -11,6 +11,8 @@ namespace Enabel\CodingStandard; +use Enabel\CodingStandard\Command\CiAddCommand; +use Enabel\CodingStandard\Command\CiUpdateCommand; use Enabel\CodingStandard\Command\InitCommand; use Symfony\Component\Console\Application as BaseApplication; @@ -24,6 +26,8 @@ public function __construct() parent::__construct(self::NAME, self::VERSION); $this->addCommand(new InitCommand()); + $this->addCommand(new CiUpdateCommand()); + $this->addCommand(new CiAddCommand()); $this->setDefaultCommand('init'); } } diff --git a/src/Command/CiAddCommand.php b/src/Command/CiAddCommand.php new file mode 100644 index 0000000..a68c455 --- /dev/null +++ b/src/Command/CiAddCommand.php @@ -0,0 +1,106 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\CodingStandard\Command; + +use Enabel\CodingStandard\Detector\ProjectDetector; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Filesystem; + +#[AsCommand( + name: 'ci:add', + description: 'Add CI configuration for a new provider', +)] +final class CiAddCommand extends Command +{ + private const array VALID_PROVIDERS = ['gitlab', 'github', 'azure']; + + protected function configure(): void + { + CiCommandHelper::addCiOptions($this); + + $this + ->addOption('provider', null, InputOption::VALUE_REQUIRED, 'CI provider (gitlab, github, azure)') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Overwrite if provider already exists'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $filesystem = new Filesystem(); + + $outputDir = $input->getOption('output-dir'); + if (!\is_string($outputDir)) { + $outputDir = '.'; + } + $outputDir = realpath($outputDir) ?: $outputDir; + + $provider = $input->getOption('provider'); + + if (!\is_string($provider) || '' === $provider) { + if ($input->isInteractive()) { + /** @var string $provider */ + $provider = $io->choice('Which CI provider?', self::VALID_PROVIDERS); + } else { + $io->error('The --provider option is required in non-interactive mode.'); + + return Command::FAILURE; + } + } + + if (!\in_array($provider, self::VALID_PROVIDERS, true)) { + $io->error(sprintf('Invalid provider "%s". Valid providers: %s', (string) $provider, implode(', ', self::VALID_PROVIDERS))); + + return Command::FAILURE; + } + + $detector = new ProjectDetector($outputDir); + $existingProviders = $detector->detectCiProviders(); + $force = (bool) $input->getOption('force'); + + if (\in_array($provider, $existingProviders, true) && !$force) { + $io->error(sprintf('CI configuration for "%s" already exists. Use --force to overwrite.', $provider)); + + return Command::FAILURE; + } + + $helper = new CiCommandHelper(); + $files = $helper->generateCiFiles($input, $outputDir, $detector, [$provider]); + + $writtenCount = 0; + foreach ($files as $relativePath => $content) { + $fullPath = $outputDir . '/' . $relativePath; + $directory = \dirname($fullPath); + + if (!is_dir($directory)) { + $filesystem->mkdir($directory); + } + + $filesystem->dumpFile($fullPath, $content); + $io->writeln(sprintf(' Created: %s', $relativePath)); + ++$writtenCount; + } + + $io->newLine(); + $io->success(sprintf( + 'CI configuration for %s added. %d files written.', + $provider, + $writtenCount, + )); + + return Command::SUCCESS; + } +} diff --git a/src/Command/CiCommandHelper.php b/src/Command/CiCommandHelper.php new file mode 100644 index 0000000..2523918 --- /dev/null +++ b/src/Command/CiCommandHelper.php @@ -0,0 +1,153 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\CodingStandard\Command; + +use Enabel\CodingStandard\Config\Configuration; +use Enabel\CodingStandard\Config\ConflictResolution; +use Enabel\CodingStandard\Detector\ProjectDetector; +use Enabel\CodingStandard\Generator\AzureDevOpsGenerator; +use Enabel\CodingStandard\Generator\GeneratorInterface; +use Enabel\CodingStandard\Generator\GitHubActionsGenerator; +use Enabel\CodingStandard\Generator\GitLabCiGenerator; +use Enabel\CodingStandard\Template\TemplateRenderer; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + +final class CiCommandHelper +{ + public static function addCiOptions(Command $command): void + { + $command + ->addOption('php-version', null, InputOption::VALUE_REQUIRED, 'PHP version (8.3, 8.4, 8.5)') + ->addOption('symfony', null, InputOption::VALUE_REQUIRED, 'Symfony version (7.4, 8.0) or "no"') + ->addOption('database', null, InputOption::VALUE_REQUIRED, 'Database type (mariadb, mysql, postgresql)') + ->addOption('database-version', null, InputOption::VALUE_REQUIRED, 'Database version') + ->addOption('php-cs-fixer', null, InputOption::VALUE_NEGATABLE, 'Include PHP-CS-Fixer') + ->addOption('phpstan', null, InputOption::VALUE_NEGATABLE, 'Include PHPStan') + ->addOption('rector', null, InputOption::VALUE_NEGATABLE, 'Include Rector') + ->addOption('output-dir', 'o', InputOption::VALUE_REQUIRED, 'Output directory', '.'); + } + + public function buildConfiguration(InputInterface $input, string $outputDir, ProjectDetector $detector, string $ciProvider): Configuration + { + $projectName = $detector->getProjectName() ?? basename($outputDir); + + $phpVersion = $this->resolveOption($input, 'php-version') ?? $detector->getPhpVersion() ?? '8.4'; + + $symfony = $this->resolveOption($input, 'symfony'); + if (null === $symfony) { + $isSymfony = $detector->isSymfonyProject(); + $symfonyVersion = $isSymfony ? ($detector->getSymfonyVersion() ?? '8.0') : null; + } else { + $isSymfony = 'no' !== $symfony; + $symfonyVersion = $isSymfony ? $symfony : null; + } + + $databaseType = $this->resolveOption($input, 'database'); + $databaseVersion = $this->resolveOption($input, 'database-version'); + + if (null === $databaseType) { + $dbInfo = $detector->detectDatabase(); + if (null !== $dbInfo) { + $databaseType = $dbInfo['type']; + $databaseVersion ??= $dbInfo['version']; + } + } + + $includePhpCsFixer = $this->resolveNegatableOption($input, 'php-cs-fixer') ?? $detector->hasPhpCsFixer(); + $includePhpStan = $this->resolveNegatableOption($input, 'phpstan') ?? $detector->hasPhpStan(); + $includeRector = $this->resolveNegatableOption($input, 'rector') ?? $detector->hasRector(); + + return new Configuration( + projectName: $projectName, + phpVersion: $phpVersion, + phpstanLevel: 9, + isSymfonyProject: $isSymfony, + symfonyVersion: $symfonyVersion, + ciProvider: $ciProvider, + devEnvironment: 'local', + includeMakefile: false, + includePhpCsFixer: $includePhpCsFixer, + includePhpStan: $includePhpStan, + includeRector: $includeRector, + includePhpUnit: $detector->hasPhpUnit(), + srcPath: 'src', + testsPath: 'tests', + outputDir: $outputDir, + conflictResolution: ConflictResolution::REPLACE, + databaseType: $databaseType, + databaseVersion: $databaseVersion, + ); + } + + /** + * @param list $providers + * + * @return array Map of relative file paths to content + */ + public function generateCiFiles(InputInterface $input, string $outputDir, ProjectDetector $detector, array $providers): array + { + $templateRenderer = new TemplateRenderer($this->getTemplatesPath()); + $generators = $this->createCiGenerators($templateRenderer); + $files = []; + + foreach ($providers as $provider) { + $config = $this->buildConfiguration($input, $outputDir, $detector, $provider); + + foreach ($generators as $generator) { + if ($generator->supports($config)) { + $files = array_merge($files, $generator->generate($config)); + } + } + } + + return $files; + } + + /** + * @return list + */ + private function createCiGenerators(TemplateRenderer $renderer): array + { + return [ + new GitLabCiGenerator($renderer), + new GitHubActionsGenerator($renderer), + new AzureDevOpsGenerator($renderer), + ]; + } + + private function getTemplatesPath(): string + { + return dirname(__DIR__, 2) . '/templates'; + } + + private function resolveOption(InputInterface $input, string $name): ?string + { + if (!$input->hasParameterOption('--' . $name)) { + return null; + } + + $value = $input->getOption($name); + + return \is_string($value) && '' !== $value ? $value : null; + } + + private function resolveNegatableOption(InputInterface $input, string $name): ?bool + { + if (!$input->hasParameterOption('--' . $name) && !$input->hasParameterOption('--no-' . $name)) { + return null; + } + + return (bool) $input->getOption($name); + } +} diff --git a/src/Command/CiUpdateCommand.php b/src/Command/CiUpdateCommand.php new file mode 100644 index 0000000..6a272e1 --- /dev/null +++ b/src/Command/CiUpdateCommand.php @@ -0,0 +1,79 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\CodingStandard\Command; + +use Enabel\CodingStandard\Detector\ProjectDetector; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Filesystem; + +#[AsCommand( + name: 'ci:update', + description: 'Update existing CI configuration', +)] +final class CiUpdateCommand extends Command +{ + protected function configure(): void + { + CiCommandHelper::addCiOptions($this); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $filesystem = new Filesystem(); + + $outputDir = $input->getOption('output-dir'); + if (!\is_string($outputDir)) { + $outputDir = '.'; + } + $outputDir = realpath($outputDir) ?: $outputDir; + + $detector = new ProjectDetector($outputDir); + $providers = $detector->detectCiProviders(); + + if ([] === $providers) { + $io->error('No existing CI configuration detected. Use ci:add to add a new CI provider.'); + + return Command::FAILURE; + } + + $helper = new CiCommandHelper(); + $files = $helper->generateCiFiles($input, $outputDir, $detector, $providers); + + $writtenCount = 0; + foreach ($files as $relativePath => $content) { + $fullPath = $outputDir . '/' . $relativePath; + $directory = \dirname($fullPath); + + if (!is_dir($directory)) { + $filesystem->mkdir($directory); + } + + $filesystem->dumpFile($fullPath, $content); + $io->writeln(sprintf(' Updated: %s', $relativePath)); + ++$writtenCount; + } + + $io->newLine(); + $io->success(sprintf( + 'CI configuration updated for %s. %d files written.', + implode(', ', $providers), + $writtenCount, + )); + + return Command::SUCCESS; + } +} diff --git a/src/Detector/ProjectDetector.php b/src/Detector/ProjectDetector.php index c412b33..3b4341f 100644 --- a/src/Detector/ProjectDetector.php +++ b/src/Detector/ProjectDetector.php @@ -13,11 +13,83 @@ final readonly class ProjectDetector { + private const array CI_PROVIDER_FILES = [ + 'gitlab' => '.gitlab-ci.yml', + 'github' => '.github/workflows/ci.yml', + 'azure' => 'azure-pipelines.yml', + ]; + public function __construct( private string $projectDir, ) { } + /** + * @return list CI providers detected ('gitlab', 'github', 'azure') + */ + public function detectCiProviders(): array + { + $providers = []; + + foreach (self::CI_PROVIDER_FILES as $provider => $file) { + if (file_exists($this->projectDir . '/' . $file)) { + $providers[] = $provider; + } + } + + return $providers; + } + + /** + * @return array{type: string, version: string}|null + */ + public function detectDatabase(): ?array + { + $composePath = $this->projectDir . '/compose.yaml'; + if (!file_exists($composePath)) { + return null; + } + + $content = file_get_contents($composePath); + if (false === $content) { + return null; + } + + $patterns = [ + 'mariadb' => '/image:\s*mariadb:(\d+\.\d+)/', + 'mysql' => '/image:\s*mysql:(\d+\.\d+)/', + 'postgresql' => '/image:\s*postgres:(\d+)/', + ]; + + foreach ($patterns as $type => $pattern) { + if (preg_match($pattern, $content, $matches)) { + return ['type' => $type, 'version' => $matches[1]]; + } + } + + return null; + } + + public function hasPhpCsFixer(): bool + { + return file_exists($this->projectDir . '/.php-cs-fixer.dist.php'); + } + + public function hasPhpStan(): bool + { + return file_exists($this->projectDir . '/phpstan.neon'); + } + + public function hasRector(): bool + { + return file_exists($this->projectDir . '/rector.php'); + } + + public function hasPhpUnit(): bool + { + return file_exists($this->projectDir . '/phpunit.dist.xml'); + } + public function getProjectName(): ?string { $composerJson = $this->readComposerJson(); diff --git a/src/Generator/GitHubActionsGenerator.php b/src/Generator/GitHubActionsGenerator.php index 683eb7f..815d473 100644 --- a/src/Generator/GitHubActionsGenerator.php +++ b/src/Generator/GitHubActionsGenerator.php @@ -17,21 +17,24 @@ final class GitHubActionsGenerator extends AbstractGenerator { public function generate(Configuration $config): array { + $variables = [ + 'phpVersion' => $config->phpVersion, + 'isSymfony' => $config->isSymfonyProject, + 'includePhpCsFixer' => $config->includePhpCsFixer, + 'includePhpStan' => $config->includePhpStan, + 'includeRector' => $config->includeRector, + 'hasDatabase' => $config->hasDatabase(), + 'databaseType' => $config->databaseType, + 'databaseImage' => $config->getDatabaseImage(), + 'databasePort' => $config->getDatabasePort(), + 'databaseUrl' => $config->getDatabaseUrl(), + 'databaseEnvVars' => $config->getDatabaseEnvVars(), + 'phpDatabaseExtension' => $config->getPhpDatabaseExtension(), + ]; + return [ - '.github/workflows/ci.yml' => $this->render('ci/github/workflows/ci.yml.tpl', [ - 'phpVersion' => $config->phpVersion, - 'isSymfony' => $config->isSymfonyProject, - 'includePhpCsFixer' => $config->includePhpCsFixer, - 'includePhpStan' => $config->includePhpStan, - 'includeRector' => $config->includeRector, - 'hasDatabase' => $config->hasDatabase(), - 'databaseType' => $config->databaseType, - 'databaseImage' => $config->getDatabaseImage(), - 'databasePort' => $config->getDatabasePort(), - 'databaseUrl' => $config->getDatabaseUrl(), - 'databaseEnvVars' => $config->getDatabaseEnvVars(), - 'phpDatabaseExtension' => $config->getPhpDatabaseExtension(), - ]), + '.github/workflows/ci.yml' => $this->render('ci/github/workflows/ci.yml.tpl', $variables), + '.github/ci/Dockerfile' => $this->render('ci/Dockerfile.tpl', $variables), ]; } @@ -42,6 +45,6 @@ public function supports(Configuration $config): bool public function getTargetFiles(): array { - return ['.github/workflows/ci.yml']; + return ['.github/workflows/ci.yml', '.github/ci/Dockerfile']; } } diff --git a/src/Generator/GitLabCiGenerator.php b/src/Generator/GitLabCiGenerator.php index 7c0fa71..6cc8e61 100644 --- a/src/Generator/GitLabCiGenerator.php +++ b/src/Generator/GitLabCiGenerator.php @@ -17,21 +17,24 @@ final class GitLabCiGenerator extends AbstractGenerator { public function generate(Configuration $config): array { + $variables = [ + 'phpVersion' => $config->phpVersion, + 'isSymfony' => $config->isSymfonyProject, + 'includePhpCsFixer' => $config->includePhpCsFixer, + 'includePhpStan' => $config->includePhpStan, + 'includeRector' => $config->includeRector, + 'hasDatabase' => $config->hasDatabase(), + 'databaseType' => $config->databaseType, + 'databaseImage' => $config->getDatabaseImage(), + 'databasePort' => $config->getDatabasePort(), + 'databaseUrl' => $config->getDatabaseUrl(), + 'databaseEnvVars' => $config->getDatabaseEnvVars(), + 'phpDatabaseExtension' => $config->getPhpDatabaseExtension(), + ]; + return [ - '.gitlab-ci.yml' => $this->render('ci/gitlab-ci.yml.tpl', [ - 'phpVersion' => $config->phpVersion, - 'isSymfony' => $config->isSymfonyProject, - 'includePhpCsFixer' => $config->includePhpCsFixer, - 'includePhpStan' => $config->includePhpStan, - 'includeRector' => $config->includeRector, - 'hasDatabase' => $config->hasDatabase(), - 'databaseType' => $config->databaseType, - 'databaseImage' => $config->getDatabaseImage(), - 'databasePort' => $config->getDatabasePort(), - 'databaseUrl' => $config->getDatabaseUrl(), - 'databaseEnvVars' => $config->getDatabaseEnvVars(), - 'phpDatabaseExtension' => $config->getPhpDatabaseExtension(), - ]), + '.gitlab-ci.yml' => $this->render('ci/gitlab-ci.yml.tpl', $variables), + '.gitlab/ci/Dockerfile' => $this->render('ci/Dockerfile.tpl', $variables), ]; } @@ -42,6 +45,6 @@ public function supports(Configuration $config): bool public function getTargetFiles(): array { - return ['.gitlab-ci.yml']; + return ['.gitlab-ci.yml', '.gitlab/ci/Dockerfile']; } } diff --git a/templates/ci/Dockerfile.tpl b/templates/ci/Dockerfile.tpl new file mode 100644 index 0000000..d1b30d3 --- /dev/null +++ b/templates/ci/Dockerfile.tpl @@ -0,0 +1,23 @@ +FROM php:-cli + +RUN apt-get update && apt-get install -y \ + git \ + unzip \ + libicu-dev \ + libzip-dev \ + + libpq-dev \ + + && docker-php-ext-install \ + intl \ + zip \ + + pdo \ + \ + + && pecl install pcov \ + && docker-php-ext-enable pcov \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer diff --git a/templates/ci/github/workflows/ci.yml.tpl b/templates/ci/github/workflows/ci.yml.tpl index 49d282a..f48673e 100644 --- a/templates/ci/github/workflows/ci.yml.tpl +++ b/templates/ci/github/workflows/ci.yml.tpl @@ -6,19 +6,50 @@ on: pull_request: branches: [main, master] +permissions: + contents: read + packages: write + +env: + CI_IMAGE: ghcr.io/${{ github.repository }}/ci:php + + jobs: - build: + build-image: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Setup PHP - uses: shivammathur/setup-php@v2 + - name: Log in to GHCR + uses: docker/login-action@v3 with: - php-version: '' - extensions: intl, zip, pdo, pdo_ + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - coverage: pcov + - name: Build and push CI image + uses: docker/build-push-action@v6 + with: + context: . + file: .github/ci/Dockerfile + push: true + tags: ${{ env.CI_IMAGE }} + cache-from: type=gha + cache-to: type=gha,mode=max + + build: + needs: [build-image] + runs-on: ubuntu-latest + container: + image: ${{ env.CI_IMAGE }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 - name: Get Composer cache directory id: composer-cache @@ -61,17 +92,16 @@ jobs: lint: - needs: build + needs: [build-image, build] runs-on: ubuntu-latest + container: + image: ${{ env.CI_IMAGE }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '' - extensions: intl, zip - - name: Install dependencies run: composer install --prefer-dist --no-progress @@ -89,17 +119,16 @@ jobs: analyze: - needs: build + needs: [build-image, build] runs-on: ubuntu-latest + container: + image: ${{ env.CI_IMAGE }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '' - extensions: intl, zip - - name: Install dependencies run: composer install --prefer-dist --no-progress @@ -120,7 +149,7 @@ jobs: test: - needs: build + needs: [build-image, build] runs-on: ubuntu-latest services: @@ -132,26 +161,20 @@ jobs: : - ports: - - : - options: --health-cmd pg_isready --health-interval=10s --health-timeout=5s --health-retries=3 options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=10s --health-timeout=5s --health-retries=3 + container: + image: ${{ env.CI_IMAGE }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '' - extensions: intl, zip, pdo, pdo_ - - coverage: pcov - - name: Install dependencies run: composer install --prefer-dist --no-progress @@ -164,19 +187,17 @@ jobs: - name: Create database run: bin/console doctrine:database:create --if-not-exists --env=test env: - DATABASE_URL: - + DATABASE_URL: "" - name: Run migrations run: bin/console doctrine:migrations:migrate --no-interaction --allow-no-migration --env=test env: - DATABASE_URL: - + DATABASE_URL: "" - name: Run tests run: bin/phpunit --testdox env: - DATABASE_URL: + DATABASE_URL: "" diff --git a/templates/ci/gitlab-ci.yml.tpl b/templates/ci/gitlab-ci.yml.tpl index f9dc6d2..137b9a6 100644 --- a/templates/ci/gitlab-ci.yml.tpl +++ b/templates/ci/gitlab-ci.yml.tpl @@ -1,4 +1,5 @@ stages: + - .pre - build - lint - analyze @@ -13,9 +14,11 @@ variables: COMPOSER_ALLOW_SUPERUSER: 1 COMPOSER_NO_INTERACTION: 1 SECRET_DETECTION_ENABLED: 'true' + CI_IMAGE: $CI_REGISTRY_IMAGE/ci:php -.php-image: &php-image - image: php:-cli + +.ci-image: &ci-image + image: $CI_IMAGE .composer-cache: &composer-cache cache: @@ -33,20 +36,40 @@ variables: policy: pull +# ==================== +# Pre Stage — Build CI Image +# ==================== + +build:image: + stage: .pre + image: + name: gcr.io/kaniko-project/executor:v1.23.2-debug + entrypoint: [""] + script: + - mkdir -p /kaniko/.docker + - echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(printf '%s:%s' "$CI_REGISTRY_USER" "$CI_REGISTRY_PASSWORD" | base64)\"}}}" > /kaniko/.docker/config.json + - >- + /kaniko/executor + --context $CI_PROJECT_DIR + --dockerfile $CI_PROJECT_DIR/.gitlab/ci/Dockerfile + --destination $CI_IMAGE + --cache=true + --cache-repo $CI_REGISTRY_IMAGE/ci/cache + rules: + - changes: + - .gitlab/ci/Dockerfile + - if: $BUILD_CI_IMAGE == "true" + - when: manual + allow_failure: true + # ==================== # Build Stage # ==================== build: - <<: *php-image + <<: *ci-image stage: build - before_script: - - apt-get update && apt-get install -y git unzip libicu-dev libzip-dev libpq-dev - - - docker-php-ext-install intl zip pdo - - - pecl install pcov && docker-php-ext-enable pcov - - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + needs: [build:image] script: - composer validate --no-check-publish - composer install --prefer-dist --no-progress @@ -80,46 +103,34 @@ build: # ==================== lint:yaml: - <<: *php-image + <<: *ci-image <<: *composer-cache stage: lint - needs: [build] - before_script: - - apt-get update && apt-get install -y libicu-dev libzip-dev - - docker-php-ext-install intl zip + needs: [build:image, build] script: - bin/console lint:yaml config --parse-tags lint:twig: - <<: *php-image + <<: *ci-image <<: *composer-cache stage: lint - needs: [build] - before_script: - - apt-get update && apt-get install -y libicu-dev libzip-dev - - docker-php-ext-install intl zip + needs: [build:image, build] script: - bin/console lint:twig templates lint:container: - <<: *php-image + <<: *ci-image <<: *composer-cache stage: lint - needs: [build] - before_script: - - apt-get update && apt-get install -y libicu-dev libzip-dev - - docker-php-ext-install intl zip + needs: [build:image, build] script: - bin/console lint:container lint:composer: - <<: *php-image + <<: *ci-image <<: *composer-cache stage: lint - needs: [build] - before_script: - - apt-get update && apt-get install -y git unzip - - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + needs: [build:image, build] script: - composer validate --no-check-publish @@ -130,23 +141,20 @@ lint:composer: php-cs-fixer: - <<: *php-image + <<: *ci-image <<: *composer-cache stage: analyze - needs: [build] + needs: [build:image, build] script: - tools/php-cs-fixer/vendor/bin/php-cs-fixer fix --dry-run --diff phpstan: - <<: *php-image + <<: *ci-image <<: *composer-cache stage: analyze - needs: [build] - before_script: - - apt-get update && apt-get install -y libicu-dev libzip-dev - - docker-php-ext-install intl zip + needs: [build:image, build] script: - tools/phpstan/vendor/bin/phpstan analyse @@ -156,9 +164,9 @@ phpstan: # ==================== phpunit: - <<: *php-image + <<: *ci-image stage: test - needs: [build] + needs: [build:image, build] services: - name: @@ -172,14 +180,7 @@ phpunit: variables: APP_ENV: test DATABASE_URL: "" - before_script: - - apt-get update && apt-get install -y git unzip libicu-dev libzip-dev libpq-dev - - - docker-php-ext-install intl zip pdo - - - pecl install pcov && docker-php-ext-enable pcov - # Wait for database to be ready - | for i in $(seq 1 30); do diff --git a/tests/Command/CiAddCommandTest.php b/tests/Command/CiAddCommandTest.php new file mode 100644 index 0000000..6f91665 --- /dev/null +++ b/tests/Command/CiAddCommandTest.php @@ -0,0 +1,141 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\CodingStandard\Tests\Command; + +use Enabel\CodingStandard\Command\CiAddCommand; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Filesystem\Filesystem; + +final class CiAddCommandTest extends TestCase +{ + private string $tempDir; + private Filesystem $filesystem; + + protected function setUp(): void + { + $this->filesystem = new Filesystem(); + $this->tempDir = sys_get_temp_dir() . '/ci-add-command-test-' . uniqid(); + mkdir($this->tempDir); + + $this->writeComposerJson([ + 'name' => 'enabel/test-project', + 'require' => ['php' => '>=8.4'], + ]); + + file_put_contents($this->tempDir . '/.php-cs-fixer.dist.php', 'tempDir . '/phpstan.neon', 'parameters:'); + } + + protected function tearDown(): void + { + $this->filesystem->remove($this->tempDir); + } + + public function testMissingProviderInNonInteractiveModeReturnFailure(): void + { + $tester = $this->createCommandTester(); + + $tester->execute(['--output-dir' => $this->tempDir], ['interactive' => false]); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('--provider option is required', $tester->getDisplay()); + } + + public function testProviderAlreadyExistsReturnFailure(): void + { + file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]'); + + $tester = $this->createCommandTester(); + + $tester->execute(['--provider' => 'gitlab', '--output-dir' => $this->tempDir], ['interactive' => false]); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('already exists', $tester->getDisplay()); + } + + public function testForceOverwritesExistingProvider(): void + { + file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]'); + + $tester = $this->createCommandTester(); + + $tester->execute( + ['--provider' => 'gitlab', '--force' => true, '--output-dir' => $this->tempDir], + ['interactive' => false], + ); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertFileExists($this->tempDir . '/.gitlab-ci.yml'); + } + + public function testGeneratesGitHubFiles(): void + { + $tester = $this->createCommandTester(); + + $tester->execute(['--provider' => 'github', '--output-dir' => $this->tempDir], ['interactive' => false]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertFileExists($this->tempDir . '/.github/workflows/ci.yml'); + self::assertFileExists($this->tempDir . '/.github/ci/Dockerfile'); + + $ci = (string) file_get_contents($this->tempDir . '/.github/workflows/ci.yml'); + self::assertStringContainsString('name:', $ci); + + $dockerfile = (string) file_get_contents($this->tempDir . '/.github/ci/Dockerfile'); + self::assertStringContainsString('php:8.4', $dockerfile); + } + + public function testGeneratesGitLabFiles(): void + { + $tester = $this->createCommandTester(); + + $tester->execute(['--provider' => 'gitlab', '--output-dir' => $this->tempDir], ['interactive' => false]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertFileExists($this->tempDir . '/.gitlab-ci.yml'); + self::assertFileExists($this->tempDir . '/.gitlab/ci/Dockerfile'); + + $ci = (string) file_get_contents($this->tempDir . '/.gitlab-ci.yml'); + self::assertStringContainsString('stages:', $ci); + + $dockerfile = (string) file_get_contents($this->tempDir . '/.gitlab/ci/Dockerfile'); + self::assertStringContainsString('php:8.4', $dockerfile); + } + + public function testInvalidProviderReturnFailure(): void + { + $tester = $this->createCommandTester(); + + $tester->execute(['--provider' => 'jenkins', '--output-dir' => $this->tempDir], ['interactive' => false]); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('Invalid provider', $tester->getDisplay()); + } + + private function createCommandTester(): CommandTester + { + return new CommandTester(new CiAddCommand()); + } + + /** + * @param array $data + */ + private function writeComposerJson(array $data): void + { + file_put_contents( + $this->tempDir . '/composer.json', + json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES), + ); + } +} diff --git a/tests/Command/CiUpdateCommandTest.php b/tests/Command/CiUpdateCommandTest.php new file mode 100644 index 0000000..9bad909 --- /dev/null +++ b/tests/Command/CiUpdateCommandTest.php @@ -0,0 +1,143 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\CodingStandard\Tests\Command; + +use Enabel\CodingStandard\Command\CiUpdateCommand; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Filesystem\Filesystem; + +final class CiUpdateCommandTest extends TestCase +{ + private string $tempDir; + private Filesystem $filesystem; + + protected function setUp(): void + { + $this->filesystem = new Filesystem(); + $this->tempDir = sys_get_temp_dir() . '/ci-update-command-test-' . uniqid(); + mkdir($this->tempDir); + + // Simulate a PHP project with composer.json + $this->writeComposerJson([ + 'name' => 'enabel/test-project', + 'require' => [ + 'php' => '>=8.4', + 'symfony/framework-bundle' => '^7.4', + ], + ]); + + // Simulate tool config files so they are detected + file_put_contents($this->tempDir . '/.php-cs-fixer.dist.php', 'tempDir . '/phpstan.neon', 'parameters:'); + } + + protected function tearDown(): void + { + $this->filesystem->remove($this->tempDir); + } + + public function testFailsWhenNoCiDetected(): void + { + $tester = $this->executeCommand(); + + self::assertSame(Command::FAILURE, $tester->getStatusCode()); + self::assertStringContainsString('No existing CI configuration', $tester->getDisplay()); + } + + public function testRegeneratesGitLabWhenGitLabCiExists(): void + { + file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]'); + + $tester = $this->executeCommand(); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + self::assertFileExists($this->tempDir . '/.gitlab-ci.yml'); + self::assertFileExists($this->tempDir . '/.gitlab/ci/Dockerfile'); + } + + public function testRegeneratesBothWhenGitLabAndGitHubExist(): void + { + file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]'); + $this->filesystem->mkdir($this->tempDir . '/.github/workflows'); + file_put_contents($this->tempDir . '/.github/workflows/ci.yml', 'name: CI'); + + $tester = $this->executeCommand(); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + + // GitLab files + self::assertFileExists($this->tempDir . '/.gitlab-ci.yml'); + self::assertFileExists($this->tempDir . '/.gitlab/ci/Dockerfile'); + + // GitHub files + self::assertFileExists($this->tempDir . '/.github/workflows/ci.yml'); + self::assertFileExists($this->tempDir . '/.github/ci/Dockerfile'); + } + + public function testOverridePhpVersionChangesDockerfile(): void + { + file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]'); + + $tester = $this->executeCommand(['--php-version' => '8.5']); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + + $dockerfile = (string) file_get_contents($this->tempDir . '/.gitlab/ci/Dockerfile'); + self::assertStringContainsString('php:8.5-cli', $dockerfile); + } + + public function testOverrideDatabaseAddsDbToCi(): void + { + file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]'); + + $tester = $this->executeCommand([ + '--database' => 'mariadb', + '--database-version' => '11.4', + ]); + + self::assertSame(Command::SUCCESS, $tester->getStatusCode()); + + $dockerfile = (string) file_get_contents($this->tempDir . '/.gitlab/ci/Dockerfile'); + self::assertStringContainsString('pdo_mysql', $dockerfile); + + $ciYml = (string) file_get_contents($this->tempDir . '/.gitlab-ci.yml'); + self::assertStringContainsString('mariadb', $ciYml); + } + + /** + * @param array $input + */ + private function executeCommand(array $input = []): CommandTester + { + $command = new CiUpdateCommand(); + $tester = new CommandTester($command); + $tester->execute( + array_merge(['--output-dir' => $this->tempDir], $input), + ['interactive' => false], + ); + + return $tester; + } + + /** + * @param array $data + */ + private function writeComposerJson(array $data): void + { + file_put_contents( + $this->tempDir . '/composer.json', + json_encode($data, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES), + ); + } +} diff --git a/tests/Detector/ProjectDetectorTest.php b/tests/Detector/ProjectDetectorTest.php index 2fe08fd..86736bf 100644 --- a/tests/Detector/ProjectDetectorTest.php +++ b/tests/Detector/ProjectDetectorTest.php @@ -13,26 +13,23 @@ use Enabel\CodingStandard\Detector\ProjectDetector; use PHPUnit\Framework\TestCase; +use Symfony\Component\Filesystem\Filesystem; final class ProjectDetectorTest extends TestCase { private string $tempDir; + private Filesystem $filesystem; protected function setUp(): void { + $this->filesystem = new Filesystem(); $this->tempDir = sys_get_temp_dir() . '/project-detector-test-' . uniqid(); mkdir($this->tempDir); } protected function tearDown(): void { - foreach (['composer.json', 'composer.lock'] as $file) { - $path = $this->tempDir . '/' . $file; - if (file_exists($path)) { - unlink($path); - } - } - rmdir($this->tempDir); + $this->filesystem->remove($this->tempDir); } public function testGetProjectNameFromComposerJson(): void @@ -149,6 +146,165 @@ public function testGetSymfonyVersionReturnsNullWhenNotSymfony(): void self::assertNull($detector->getSymfonyVersion()); } + // --- CI Provider detection --- + + public function testDetectCiProvidersReturnsEmptyWhenNoCiFiles(): void + { + $detector = new ProjectDetector($this->tempDir); + + self::assertSame([], $detector->detectCiProviders()); + } + + public function testDetectCiProvidersDetectsGitlab(): void + { + file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]'); + + $detector = new ProjectDetector($this->tempDir); + + self::assertSame(['gitlab'], $detector->detectCiProviders()); + } + + public function testDetectCiProvidersDetectsGithub(): void + { + $this->filesystem->mkdir($this->tempDir . '/.github/workflows'); + file_put_contents($this->tempDir . '/.github/workflows/ci.yml', 'name: CI'); + + $detector = new ProjectDetector($this->tempDir); + + self::assertSame(['github'], $detector->detectCiProviders()); + } + + public function testDetectCiProvidersDetectsAzure(): void + { + file_put_contents($this->tempDir . '/azure-pipelines.yml', 'trigger: [main]'); + + $detector = new ProjectDetector($this->tempDir); + + self::assertSame(['azure'], $detector->detectCiProviders()); + } + + public function testDetectCiProvidersDetectsMultiple(): void + { + file_put_contents($this->tempDir . '/.gitlab-ci.yml', 'stages: [test]'); + $this->filesystem->mkdir($this->tempDir . '/.github/workflows'); + file_put_contents($this->tempDir . '/.github/workflows/ci.yml', 'name: CI'); + + $detector = new ProjectDetector($this->tempDir); + + self::assertSame(['gitlab', 'github'], $detector->detectCiProviders()); + } + + // --- Database detection --- + + public function testDetectDatabaseReturnsNullWithoutComposeYaml(): void + { + $detector = new ProjectDetector($this->tempDir); + + self::assertNull($detector->detectDatabase()); + } + + public function testDetectDatabaseDetectsMariadb(): void + { + file_put_contents($this->tempDir . '/compose.yaml', "services:\n database:\n image: mariadb:11.4\n"); + + $detector = new ProjectDetector($this->tempDir); + + self::assertSame(['type' => 'mariadb', 'version' => '11.4'], $detector->detectDatabase()); + } + + public function testDetectDatabaseDetectsMysql(): void + { + file_put_contents($this->tempDir . '/compose.yaml', "services:\n database:\n image: mysql:8.4\n"); + + $detector = new ProjectDetector($this->tempDir); + + self::assertSame(['type' => 'mysql', 'version' => '8.4'], $detector->detectDatabase()); + } + + public function testDetectDatabaseDetectsPostgresql(): void + { + file_put_contents($this->tempDir . '/compose.yaml', "services:\n database:\n image: postgres:17\n"); + + $detector = new ProjectDetector($this->tempDir); + + self::assertSame(['type' => 'postgresql', 'version' => '17'], $detector->detectDatabase()); + } + + public function testDetectDatabaseReturnsNullWhenNoDbImage(): void + { + file_put_contents($this->tempDir . '/compose.yaml', "services:\n app:\n image: php:8.4\n"); + + $detector = new ProjectDetector($this->tempDir); + + self::assertNull($detector->detectDatabase()); + } + + // --- Tool detection --- + + public function testHasPhpCsFixerReturnsTrueWhenFileExists(): void + { + file_put_contents($this->tempDir . '/.php-cs-fixer.dist.php', 'tempDir); + + self::assertTrue($detector->hasPhpCsFixer()); + } + + public function testHasPhpCsFixerReturnsFalseWhenFileAbsent(): void + { + $detector = new ProjectDetector($this->tempDir); + + self::assertFalse($detector->hasPhpCsFixer()); + } + + public function testHasPhpStanReturnsTrueWhenFileExists(): void + { + file_put_contents($this->tempDir . '/phpstan.neon', 'parameters:'); + + $detector = new ProjectDetector($this->tempDir); + + self::assertTrue($detector->hasPhpStan()); + } + + public function testHasPhpStanReturnsFalseWhenFileAbsent(): void + { + $detector = new ProjectDetector($this->tempDir); + + self::assertFalse($detector->hasPhpStan()); + } + + public function testHasRectorReturnsTrueWhenFileExists(): void + { + file_put_contents($this->tempDir . '/rector.php', 'tempDir); + + self::assertTrue($detector->hasRector()); + } + + public function testHasRectorReturnsFalseWhenFileAbsent(): void + { + $detector = new ProjectDetector($this->tempDir); + + self::assertFalse($detector->hasRector()); + } + + public function testHasPhpUnitReturnsTrueWhenFileExists(): void + { + file_put_contents($this->tempDir . '/phpunit.dist.xml', ''); + + $detector = new ProjectDetector($this->tempDir); + + self::assertTrue($detector->hasPhpUnit()); + } + + public function testHasPhpUnitReturnsFalseWhenFileAbsent(): void + { + $detector = new ProjectDetector($this->tempDir); + + self::assertFalse($detector->hasPhpUnit()); + } + /** * @param array $data */ diff --git a/tests/Generator/GitHubActionsGeneratorTest.php b/tests/Generator/GitHubActionsGeneratorTest.php new file mode 100644 index 0000000..50c1172 --- /dev/null +++ b/tests/Generator/GitHubActionsGeneratorTest.php @@ -0,0 +1,226 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\CodingStandard\Tests\Generator; + +use Enabel\CodingStandard\Config\Configuration; +use Enabel\CodingStandard\Config\ConflictResolution; +use Enabel\CodingStandard\Generator\GitHubActionsGenerator; +use Enabel\CodingStandard\Template\TemplateRenderer; +use PHPUnit\Framework\TestCase; + +final class GitHubActionsGeneratorTest extends TestCase +{ + private GitHubActionsGenerator $generator; + + protected function setUp(): void + { + $templatesPath = dirname(__DIR__, 2) . '/templates'; + $renderer = new TemplateRenderer($templatesPath); + $this->generator = new GitHubActionsGenerator($renderer); + } + + public function testSupportsReturnsTrueForGithub(): void + { + $config = $this->createConfiguration(ciProvider: 'github'); + + self::assertTrue($this->generator->supports($config)); + } + + public function testSupportsReturnsFalseForGitlab(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab'); + + self::assertFalse($this->generator->supports($config)); + } + + public function testSupportsReturnsFalseForNone(): void + { + $config = $this->createConfiguration(ciProvider: 'none'); + + self::assertFalse($this->generator->supports($config)); + } + + public function testGenerateReturnsTwoFiles(): void + { + $config = $this->createConfiguration(ciProvider: 'github'); + + $files = $this->generator->generate($config); + + self::assertArrayHasKey('.github/workflows/ci.yml', $files); + self::assertArrayHasKey('.github/ci/Dockerfile', $files); + } + + public function testDockerfileIsIdenticalToGitlab(): void + { + $config = $this->createConfiguration(ciProvider: 'github'); + + $files = $this->generator->generate($config); + + self::assertStringContainsString('php:8.4-cli', $files['.github/ci/Dockerfile']); + self::assertStringContainsString('pcov', $files['.github/ci/Dockerfile']); + self::assertStringContainsString('composer', $files['.github/ci/Dockerfile']); + } + + public function testCiContainsBuildImageJob(): void + { + $config = $this->createConfiguration(ciProvider: 'github'); + + $files = $this->generator->generate($config); + + $ci = $files['.github/workflows/ci.yml']; + self::assertStringContainsString('build-image:', $ci); + self::assertStringContainsString('docker/build-push-action@v6', $ci); + } + + public function testCiContainsPackagesWritePermission(): void + { + $config = $this->createConfiguration(ciProvider: 'github'); + + $files = $this->generator->generate($config); + + self::assertStringContainsString('packages: write', $files['.github/workflows/ci.yml']); + } + + public function testCiDoesNotContainSetupPhp(): void + { + $config = $this->createConfiguration(ciProvider: 'github'); + + $files = $this->generator->generate($config); + + self::assertStringNotContainsString('shivammathur/setup-php', $files['.github/workflows/ci.yml']); + } + + public function testCiJobsUseContainer(): void + { + $config = $this->createConfiguration(ciProvider: 'github'); + + $files = $this->generator->generate($config); + + $ci = $files['.github/workflows/ci.yml']; + self::assertStringContainsString('container:', $ci); + self::assertStringContainsString('image: ${{ env.CI_IMAGE }}', $ci); + } + + public function testCiWithDatabaseUsesDatabaseHostname(): void + { + $config = $this->createConfiguration(ciProvider: 'github', databaseType: 'mariadb', databaseVersion: '11.4'); + + $files = $this->generator->generate($config); + + $ci = $files['.github/workflows/ci.yml']; + self::assertStringContainsString('database', $ci); + self::assertStringContainsString('DATABASE_URL:', $ci); + } + + public function testCiWithDatabaseDoesNotExposePorts(): void + { + $config = $this->createConfiguration(ciProvider: 'github', databaseType: 'mariadb', databaseVersion: '11.4'); + + $files = $this->generator->generate($config); + + self::assertStringNotContainsString('ports:', $files['.github/workflows/ci.yml']); + } + + public function testCiWithPostgresqlDatabase(): void + { + $config = $this->createConfiguration(ciProvider: 'github', databaseType: 'postgresql', databaseVersion: '17'); + + $files = $this->generator->generate($config); + + $ci = $files['.github/workflows/ci.yml']; + self::assertStringContainsString('postgres:17', $ci); + self::assertStringContainsString('pg_isready', $ci); + + $dockerfile = $files['.github/ci/Dockerfile']; + self::assertStringContainsString('pdo_pgsql', $dockerfile); + self::assertStringContainsString('libpq-dev', $dockerfile); + } + + public function testCiWithSymfonyHasLintJob(): void + { + $config = $this->createConfiguration(ciProvider: 'github', isSymfony: true); + + $files = $this->generator->generate($config); + + $ci = $files['.github/workflows/ci.yml']; + self::assertStringContainsString('lint:', $ci); + self::assertStringContainsString('lint:yaml', $ci); + self::assertStringContainsString('lint:twig', $ci); + } + + public function testCiWithoutSymfonyHasNoLintJob(): void + { + $config = $this->createConfiguration(ciProvider: 'github', isSymfony: false); + + $files = $this->generator->generate($config); + + self::assertStringNotContainsString('lint:', $files['.github/workflows/ci.yml']); + } + + public function testCiContainsGhcrImage(): void + { + $config = $this->createConfiguration(ciProvider: 'github', phpVersion: '8.4'); + + $files = $this->generator->generate($config); + + self::assertStringContainsString('ghcr.io/${{ github.repository }}/ci:php8.4', $files['.github/workflows/ci.yml']); + } + + public function testCiJobsNeedBuildImage(): void + { + $config = $this->createConfiguration(ciProvider: 'github'); + + $files = $this->generator->generate($config); + + $ci = $files['.github/workflows/ci.yml']; + self::assertStringContainsString('needs: [build-image]', $ci); + self::assertStringContainsString('needs: [build-image, build]', $ci); + } + + public function testGetTargetFilesReturnsBothFiles(): void + { + $expected = ['.github/workflows/ci.yml', '.github/ci/Dockerfile']; + + self::assertSame($expected, $this->generator->getTargetFiles()); + } + + private function createConfiguration( + string $ciProvider = 'github', + string $phpVersion = '8.4', + bool $isSymfony = true, + bool $includePhpCsFixer = true, + bool $includePhpStan = true, + ?string $databaseType = null, + ?string $databaseVersion = null, + ): Configuration { + return new Configuration( + projectName: 'test-project', + phpVersion: $phpVersion, + phpstanLevel: 9, + isSymfonyProject: $isSymfony, + symfonyVersion: '8.0', + ciProvider: $ciProvider, + devEnvironment: 'local', + includeMakefile: false, + includePhpCsFixer: $includePhpCsFixer, + includePhpStan: $includePhpStan, + includeRector: false, + includePhpUnit: true, + srcPath: 'src', + testsPath: 'tests', + outputDir: '.', + conflictResolution: ConflictResolution::ASK, + databaseType: $databaseType, + databaseVersion: $databaseVersion, + ); + } +} diff --git a/tests/Generator/GitLabCiGeneratorTest.php b/tests/Generator/GitLabCiGeneratorTest.php new file mode 100644 index 0000000..b1c0caa --- /dev/null +++ b/tests/Generator/GitLabCiGeneratorTest.php @@ -0,0 +1,241 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\CodingStandard\Tests\Generator; + +use Enabel\CodingStandard\Config\Configuration; +use Enabel\CodingStandard\Config\ConflictResolution; +use Enabel\CodingStandard\Generator\GitLabCiGenerator; +use Enabel\CodingStandard\Template\TemplateRenderer; +use PHPUnit\Framework\TestCase; + +final class GitLabCiGeneratorTest extends TestCase +{ + private GitLabCiGenerator $generator; + + protected function setUp(): void + { + $templatesPath = dirname(__DIR__, 2) . '/templates'; + $renderer = new TemplateRenderer($templatesPath); + $this->generator = new GitLabCiGenerator($renderer); + } + + public function testSupportsReturnsTrueForGitlab(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab'); + + self::assertTrue($this->generator->supports($config)); + } + + public function testSupportsReturnsFalseForGithub(): void + { + $config = $this->createConfiguration(ciProvider: 'github'); + + self::assertFalse($this->generator->supports($config)); + } + + public function testSupportsReturnsFalseForNone(): void + { + $config = $this->createConfiguration(ciProvider: 'none'); + + self::assertFalse($this->generator->supports($config)); + } + + public function testGenerateReturnsTwoFiles(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab'); + + $files = $this->generator->generate($config); + + self::assertArrayHasKey('.gitlab-ci.yml', $files); + self::assertArrayHasKey('.gitlab/ci/Dockerfile', $files); + } + + public function testDockerfileContainsPhpVersion(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab', phpVersion: '8.4'); + + $files = $this->generator->generate($config); + + self::assertStringContainsString('php:8.4-cli', $files['.gitlab/ci/Dockerfile']); + } + + public function testDockerfileContainsPcov(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab'); + + $files = $this->generator->generate($config); + + self::assertStringContainsString('pcov', $files['.gitlab/ci/Dockerfile']); + } + + public function testDockerfileContainsComposer(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab'); + + $files = $this->generator->generate($config); + + self::assertStringContainsString('composer', $files['.gitlab/ci/Dockerfile']); + } + + public function testDockerfileContainsPdoMysqlForMariadb(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab', databaseType: 'mariadb', databaseVersion: '11.4'); + + $files = $this->generator->generate($config); + + self::assertStringContainsString('pdo_mysql', $files['.gitlab/ci/Dockerfile']); + self::assertStringNotContainsString('pdo_pgsql', $files['.gitlab/ci/Dockerfile']); + } + + public function testDockerfileContainsPdoPgsqlForPostgresql(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab', databaseType: 'postgresql', databaseVersion: '17'); + + $files = $this->generator->generate($config); + + self::assertStringContainsString('pdo_pgsql', $files['.gitlab/ci/Dockerfile']); + self::assertStringContainsString('libpq-dev', $files['.gitlab/ci/Dockerfile']); + self::assertStringNotContainsString('pdo_mysql', $files['.gitlab/ci/Dockerfile']); + } + + public function testCiContainsBuildImageJob(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab'); + + $files = $this->generator->generate($config); + + $ci = $files['.gitlab-ci.yml']; + self::assertStringContainsString('build:image:', $ci); + self::assertStringContainsString('kaniko', $ci); + self::assertStringContainsString('$CI_IMAGE', $ci); + } + + public function testCiDoesNotContainAptGet(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab'); + + $files = $this->generator->generate($config); + + self::assertStringNotContainsString('apt-get', $files['.gitlab-ci.yml']); + } + + public function testCiDoesNotContainDockerPhpExtInstall(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab'); + + $files = $this->generator->generate($config); + + self::assertStringNotContainsString('docker-php-ext-install', $files['.gitlab-ci.yml']); + } + + public function testCiWithDatabaseKeepsDbWaitInPhpunit(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab', databaseType: 'mariadb', databaseVersion: '11.4'); + + $files = $this->generator->generate($config); + + $ci = $files['.gitlab-ci.yml']; + self::assertStringContainsString('Waiting for database', $ci); + self::assertStringContainsString('PDO', $ci); + } + + public function testCiWithoutDatabaseHasNoDbWait(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab'); + + $files = $this->generator->generate($config); + + self::assertStringNotContainsString('Waiting for database', $files['.gitlab-ci.yml']); + } + + public function testCiWithSymfonyHasLintJobs(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab', isSymfony: true); + + $files = $this->generator->generate($config); + + $ci = $files['.gitlab-ci.yml']; + self::assertStringContainsString('lint:yaml:', $ci); + self::assertStringContainsString('lint:twig:', $ci); + self::assertStringContainsString('lint:container:', $ci); + self::assertStringContainsString('lint:composer:', $ci); + } + + public function testCiWithoutSymfonyHasNoLintJobs(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab', isSymfony: false); + + $files = $this->generator->generate($config); + + $ci = $files['.gitlab-ci.yml']; + self::assertStringNotContainsString('lint:yaml:', $ci); + self::assertStringNotContainsString('lint:twig:', $ci); + } + + public function testCiUsesPreStage(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab'); + + $files = $this->generator->generate($config); + + self::assertStringContainsString('.pre', $files['.gitlab-ci.yml']); + } + + public function testCiUsesImageVariable(): void + { + $config = $this->createConfiguration(ciProvider: 'gitlab', phpVersion: '8.4'); + + $files = $this->generator->generate($config); + + $ci = $files['.gitlab-ci.yml']; + self::assertStringContainsString('CI_IMAGE: $CI_REGISTRY_IMAGE/ci:php8.4', $ci); + self::assertStringContainsString('.ci-image:', $ci); + } + + public function testGetTargetFilesReturnsBothFiles(): void + { + $expected = ['.gitlab-ci.yml', '.gitlab/ci/Dockerfile']; + + self::assertSame($expected, $this->generator->getTargetFiles()); + } + + private function createConfiguration( + string $ciProvider = 'gitlab', + string $phpVersion = '8.4', + bool $isSymfony = true, + bool $includePhpCsFixer = true, + bool $includePhpStan = true, + ?string $databaseType = null, + ?string $databaseVersion = null, + ): Configuration { + return new Configuration( + projectName: 'test-project', + phpVersion: $phpVersion, + phpstanLevel: 9, + isSymfonyProject: $isSymfony, + symfonyVersion: '8.0', + ciProvider: $ciProvider, + devEnvironment: 'local', + includeMakefile: false, + includePhpCsFixer: $includePhpCsFixer, + includePhpStan: $includePhpStan, + includeRector: false, + includePhpUnit: true, + srcPath: 'src', + testsPath: 'tests', + outputDir: '.', + conflictResolution: ConflictResolution::ASK, + databaseType: $databaseType, + databaseVersion: $databaseVersion, + ); + } +}