Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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');
}
}
106 changes: 106 additions & 0 deletions src/Command/CiAddCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<?php

declare(strict_types=1);

/*
* This file is part of the Enabel Coding Standard.
* Copyright (c) Enabel <https://github.com/Enabel>
* 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(' <info>Created:</info> %s', $relativePath));
++$writtenCount;
}

$io->newLine();
$io->success(sprintf(
'CI configuration for %s added. %d files written.',
$provider,
$writtenCount,
));

return Command::SUCCESS;
}
}
153 changes: 153 additions & 0 deletions src/Command/CiCommandHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

declare(strict_types=1);

/*
* This file is part of the Enabel Coding Standard.
* Copyright (c) Enabel <https://github.com/Enabel>
* 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<string> $providers
*
* @return array<string, string> 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<GeneratorInterface>
*/
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);
}
}
79 changes: 79 additions & 0 deletions src/Command/CiUpdateCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

declare(strict_types=1);

/*
* This file is part of the Enabel Coding Standard.
* Copyright (c) Enabel <https://github.com/Enabel>
* 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(' <info>Updated:</info> %s', $relativePath));
++$writtenCount;
}

$io->newLine();
$io->success(sprintf(
'CI configuration updated for %s. %d files written.',
implode(', ', $providers),
$writtenCount,
));

return Command::SUCCESS;
}
}
Loading