diff --git a/docs/superpowers/plans/2026-07-09-pipelines-migrate-gitlab.md b/docs/superpowers/plans/2026-07-09-pipelines-migrate-gitlab.md new file mode 100644 index 000000000..7632953c1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-pipelines-migrate-gitlab.md @@ -0,0 +1,1110 @@ +# pipelines:migrate:gitlab Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a standalone `pipelines:migrate:gitlab` CLI command that converts an `acquia-pipelines.yml`/`.yaml` file into a generic `.gitlab-ci.yml`/`.gitlab-ci.yaml` file with no API calls or authentication required. + +**Architecture:** A single new command class `PipelinesMigrateGitlabCommand` in `src/Command/Pipelines/` that extends `CommandBase`. All conversion logic lives as private methods on the class. Auto-discovered by Symfony DI — no `services.yml` changes needed. Tests use the existing `CommandTestBase` / `injectCommand()` pattern with a real `Filesystem` (no API mocking). + +**Tech Stack:** PHP 8.2+, Symfony Console, `symfony/yaml`, `symfony/filesystem`, PHPUnit 10+, Prophecy. + +## Global Constraints + +- `declare(strict_types=1);` in every file. +- PHPUnit metadata uses PHP attributes (`#[Group]`, `#[DataProvider]`), not doc-comment annotations. +- Tests that mutate global state belong in `#[Group('serial')]`; everything else can run under paratest. +- Strict comparisons (`===`) throughout. +- `use` statements must be in alphabetical order (phpcs enforces this). +- No comments unless the WHY is non-obvious. +- Source `acquia-pipelines.yml`/`.yaml` file must NOT be deleted after conversion. +- Command name: `pipelines:migrate:gitlab`, alias: `p:m:g`. +- Output file extension mirrors input: `.yml` → `.yml`, `.yaml` → `.yaml`. + +--- + +## File Map + +| Action | Path | Purpose | +|--------|------|---------| +| Create | `src/Command/Pipelines/PipelinesMigrateGitlabCommand.php` | The command class | +| Create | `tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php` | All 12 test cases | +| Create | `tests/fixtures/acquia-pipelines-full.yml` | Fixture with all 5 events + services + variables | +| Modify | `tests/fixtures/acquia-pipelines.yml` | Reused as-is (already has build + post-deploy) | + +--- + +## Task 1: Test fixture with full acquia-pipelines content + +**Files:** +- Create: `tests/fixtures/acquia-pipelines-full.yml` + +**Interfaces:** +- Produces: A fixture file used by Tasks 2 and 3. Contains all 5 events (`build`, `fail-on-build`, `post-deploy`, `pr-merged`, `pr-closed`), a `services` block with `php`, `mysql`, and `composer`, and a `variables.global` block. + +- [ ] **Step 1: Create the fixture file** + +```yaml +# tests/fixtures/acquia-pipelines-full.yml +version: 1.3.0 +services: + - php: + version: '8.3' + - composer: + version: 2 + - mysql + +variables: + global: + SIMPLETEST_BASE_URL: "http://127.0.0.1:8080" + SIMPLETEST_DB: "mysql://root:root@localhost/drupal" + +events: + build: + steps: + - setup: + type: script + script: + - composer validate --no-check-all --ansi + - mysql -u root -proot -e "CREATE DATABASE IF NOT EXISTS drupal" + - validate: + type: script + script: + - composer drupal:validate + fail-on-build: + steps: + - notify: + type: script + script: + - echo "Build failed" + - curl -X POST https://hooks.example.com/notify + post-deploy: + steps: + - deploy: + type: script + script: + - echo "Deploying" + - pipelines-deploy + pr-merged: + steps: + - cleanup: + type: script + script: + - echo "PR merged" + - pipelines-deploy + pr-closed: + steps: + - teardown: + type: script + script: + - echo "PR closed" + - pipelines-deploy +``` + +- [ ] **Step 2: Verify the fixture parses cleanly** + +```bash +cd /path/to/repo && php -r "echo json_encode(\Symfony\Component\Yaml\Yaml::parseFile('tests/fixtures/acquia-pipelines-full.yml'), JSON_PRETTY_PRINT);" +``` +Expected: Valid JSON output with all 5 events present. + +--- + +## Task 2: Command skeleton + failing test (TDD gate) + +**Files:** +- Create: `src/Command/Pipelines/PipelinesMigrateGitlabCommand.php` +- Create: `tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php` + +**Interfaces:** +- Consumes: `tests/fixtures/acquia-pipelines-full.yml` from Task 1; `injectCommand()` from `TestBase`; `CommandBase` from `src/Command/CommandBase.php` +- Produces: `PipelinesMigrateGitlabCommand` class with `#[AsCommand]` attribute, `configure()` with `--path` option, and a stub `execute()` that returns `Command::SUCCESS`. Test class with `createCommand()` and one smoke test. + +- [ ] **Step 1: Write the failing smoke test** + +Note: `testFullConversionAllEvents` is defined here as a minimal smoke test. It will be replaced with a full assertion version in Task 5 — remove this version when adding the Task 5 version. + +Create `tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php`: + +```php +injectCommand(PipelinesMigrateGitlabCommand::class); + } + + // Smoke test — replaced by the full version in Task 5. + #[Group('serial')] + public function testFullConversionAllEvents(): void + { + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $outputFile = Path::join($this->projectDir, '.gitlab-ci.yml'); + $this->assertFileExists($outputFile); + $contents = Yaml::parseFile($outputFile); + $this->assertArrayHasKey('stages', $contents); + $this->assertContains('build', $contents['stages']); + } +} +``` + +- [ ] **Step 2: Run the test and confirm it fails** + +```bash +vendor/bin/phpunit --filter testFullConversionAllEvents tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +``` +Expected: FAIL — class `PipelinesMigrateGitlabCommand` not found. + +- [ ] **Step 3: Create the command skeleton** + +Create `src/Command/Pipelines/PipelinesMigrateGitlabCommand.php`: + +```php +addOption('path', null, InputOption::VALUE_REQUIRED, 'Path to the directory containing the acquia-pipelines.yml file. Defaults to the current directory.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + return Command::SUCCESS; + } +} +``` + +- [ ] **Step 4: Run the smoke test — confirm it passes** + +```bash +vendor/bin/phpunit --filter testFullConversionAllEvents tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +``` +Expected: FAIL — `assertFileExists` fails because no file is written yet. That is the correct TDD state. The class is found now. + +- [ ] **Step 5: Commit the skeleton** + +```bash +git add src/Command/Pipelines/PipelinesMigrateGitlabCommand.php \ + tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php \ + tests/fixtures/acquia-pipelines-full.yml +git commit -m "feat: scaffold PipelinesMigrateGitlabCommand with failing test" +``` + +--- + +## Task 3: File detection logic + +**Files:** +- Modify: `src/Command/Pipelines/PipelinesMigrateGitlabCommand.php` +- Modify: `tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php` + +**Interfaces:** +- Consumes: `$input->getOption('path')`, `$this->projectDir` (injected by DI), `Symfony\Component\Filesystem\Path`, `AcquiaCliException` +- Produces: Private method `resolveSourceFile(InputInterface $input): array` returning `['path' => string, 'extension' => string]`. Throws `AcquiaCliException` on missing directory or missing source file. + +- [ ] **Step 1: Write failing tests for file detection edge cases** + +Add these test methods to `PipelinesMigrateGitlabCommandTest`: + +```php +use Acquia\Cli\Exception\AcquiaCliException; + +public function testMissingInputFileThrows(): void +{ + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('No acquia-pipelines.yml or acquia-pipelines.yaml file found'); + $this->executeCommand(); +} + +public function testNonExistentPathOptionThrows(): void +{ + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage('does not exist'); + $this->executeCommand(['--path' => '/nonexistent/path/abc123']); +} + +#[Group('serial')] +public function testYamlExtensionInputProducesYamlOutput(): void +{ + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yaml') + ); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertFileExists(Path::join($this->projectDir, '.gitlab-ci.yaml')); + $this->assertFileDoesNotExist(Path::join($this->projectDir, '.gitlab-ci.yml')); +} + +#[Group('serial')] +public function testPathOption(): void +{ + $tempDir = $this->getTempDir(); + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($tempDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(['--path' => $tempDir]); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertFileExists(Path::join($tempDir, '.gitlab-ci.yml')); + $fs->remove($tempDir); +} +``` + +- [ ] **Step 2: Run the new tests — confirm they fail** + +```bash +vendor/bin/phpunit --filter "testMissingInputFileThrows|testNonExistentPathOptionThrows|testYamlExtensionInputProducesYamlOutput|testPathOption" tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +``` +Expected: All FAIL. + +- [ ] **Step 3: Implement file detection in the command** + +Replace the `execute()` method and add the private helper. Full updated file: + +```php +addOption('path', null, InputOption::VALUE_REQUIRED, 'Path to the directory containing the acquia-pipelines.yml file. Defaults to the current directory.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $sourceFile = $this->resolveSourceFile($input); + $acquiaPipelinesContents = $this->parseSourceFile($sourceFile['path']); + $gitlabCiContents = $this->convert($acquiaPipelinesContents); + $outputPath = Path::join(dirname($sourceFile['path']), '.gitlab-ci.' . $sourceFile['extension']); + + if ($this->localMachineHelper->getFilesystem()->exists($outputPath)) { + $this->io->warning("Existing $outputPath was overwritten."); + } + + $this->localMachineHelper->getFilesystem()->dumpFile( + $outputPath, + Yaml::dump($gitlabCiContents, 4, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK) + ); + + $this->io->success("Migration complete. Created $outputPath. Review the file before committing — some manual adjustments may be needed."); + + return Command::SUCCESS; + } + + /** + * @return array{path: string, extension: string} + */ + private function resolveSourceFile(InputInterface $input): array + { + $dir = $input->getOption('path') ?? $this->projectDir; + + if (!$this->localMachineHelper->getFilesystem()->exists($dir)) { + throw new AcquiaCliException("The path '{$dir}' does not exist."); + } + + foreach (['yml', 'yaml'] as $extension) { + $candidate = Path::join($dir, "acquia-pipelines.$extension"); + if ($this->localMachineHelper->getFilesystem()->exists($candidate)) { + return ['path' => $candidate, 'extension' => $extension]; + } + } + + throw new AcquiaCliException("No acquia-pipelines.yml or acquia-pipelines.yaml file found in {$dir}."); + } + + /** + * @return array + */ + private function parseSourceFile(string $path): array + { + $raw = file_get_contents($path); + if ($raw === false || trim($raw) === '') { + throw new AcquiaCliException("The file {$path} is empty or unreadable."); + } + try { + $parsed = Yaml::parse($raw); + } catch (ParseException $e) { + throw new AcquiaCliException("Failed to parse {$path}: " . $e->getMessage()); + } + if (!is_array($parsed) || !array_key_exists('events', $parsed)) { + throw new AcquiaCliException("The file {$path} does not contain an 'events' key."); + } + return $parsed; + } + + /** + * @param array $acquiaPipelinesContents + * @return array + */ + private function convert(array $acquiaPipelinesContents): array + { + // Stub — full implementation in Task 4. + return ['stages' => ['build']]; + } +} +``` + +- [ ] **Step 4: Run the file-detection tests — confirm they pass** + +```bash +vendor/bin/phpunit --filter "testMissingInputFileThrows|testNonExistentPathOptionThrows|testYamlExtensionInputProducesYamlOutput|testPathOption" tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +``` +Expected: All PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Command/Pipelines/PipelinesMigrateGitlabCommand.php \ + tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +git commit -m "feat: add file detection logic to PipelinesMigrateGitlabCommand" +``` + +--- + +## Task 4: Services and variables conversion + +**Files:** +- Modify: `src/Command/Pipelines/PipelinesMigrateGitlabCommand.php` +- Modify: `tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php` + +**Interfaces:** +- Consumes: Parsed `$acquiaPipelinesContents` array from `parseSourceFile()` +- Produces: Private methods `migrateServices(array $contents): array` and `migrateVariables(array $contents): array`, both returning partial GitLab CI arrays to be merged into the final output. + +- [ ] **Step 1: Write failing tests for services and variables** + +Add to `PipelinesMigrateGitlabCommandTest`: + +```php +#[Group('serial')] +public function testServicesMapping(): void +{ + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $contents = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertSame('php:8.3', $contents['image']); + $this->assertContains('mysql', $contents['services']); +} + +#[Group('serial')] +public function testVariablesMapping(): void +{ + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $contents = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('variables', $contents); + $this->assertArrayHasKey('SIMPLETEST_BASE_URL', $contents['variables']); + $this->assertArrayHasKey('SIMPLETEST_DB', $contents['variables']); + // The 'global' wrapper must be stripped. + $this->assertArrayNotHasKey('global', $contents['variables']); +} + +#[Group('serial')] +public function testUnknownServiceEmitsWarning(): void +{ + $fs = new Filesystem(); + $customContent = "version: 1.0\nservices:\n - redis\nevents:\n build:\n steps:\n - step1:\n script:\n - echo hi\n"; + $fs->dumpFile(Path::join($this->projectDir, 'acquia-pipelines.yml'), $customContent); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('redis', $this->getDisplay()); +} +``` + +- [ ] **Step 2: Run the new tests — confirm they fail** + +```bash +vendor/bin/phpunit --filter "testServicesMapping|testVariablesMapping|testUnknownServiceEmitsWarning" tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +``` +Expected: All FAIL. + +- [ ] **Step 3: Implement services and variables conversion** + +Replace the `convert()` stub and add the two new private methods. Add these private methods to the class and update `convert()`: + +```php +/** + * @param array $acquiaPipelinesContents + * @return array + */ +private function convert(array $acquiaPipelinesContents): array +{ + $gitlabCi = []; + + $servicesOutput = $this->migrateServices($acquiaPipelinesContents); + if (isset($servicesOutput['image'])) { + $gitlabCi['image'] = $servicesOutput['image']; + } + if (isset($servicesOutput['services'])) { + $gitlabCi['services'] = $servicesOutput['services']; + } + + $variables = $this->migrateVariables($acquiaPipelinesContents); + if (!empty($variables)) { + $gitlabCi['variables'] = $variables; + } + + // stages and jobs added in Task 5 + $gitlabCi['stages'] = ['build']; + + return $gitlabCi; +} + +/** + * @param array $contents + * @return array + */ +private function migrateServices(array $contents): array +{ + $output = []; + $composerFound = false; + + if (!array_key_exists('services', $contents)) { + return $output; + } + + foreach ($contents['services'] as $service) { + if (is_string($service)) { + $name = $service; + $version = null; + } else { + $name = array_key_first($service); + $version = $service[$name]['version'] ?? null; + } + + match ($name) { + 'php' => $output['image'] = 'php:' . $version, + 'mysql' => $output['services'][] = $version ? "mysql:$version" : 'mysql', + 'composer' => $composerFound = true, + default => $this->io->warning("Service '$name' is not supported and was skipped. Configure it manually in the generated .gitlab-ci file."), + }; + } + + // Store composer flag for use in job generation (Task 5). + if ($composerFound) { + $output['_composer'] = true; + } + + return $output; +} + +/** + * @param array $contents + * @return array + */ +private function migrateVariables(array $contents): array +{ + if (!array_key_exists('variables', $contents)) { + return []; + } + + $vars = $contents['variables']; + + // Strip the 'global' wrapper if present. + if (array_key_exists('global', $vars) && is_array($vars['global'])) { + return $vars['global']; + } + + return $vars; +} +``` + +- [ ] **Step 4: Run the services/variables tests — confirm they pass** + +```bash +vendor/bin/phpunit --filter "testServicesMapping|testVariablesMapping|testUnknownServiceEmitsWarning" tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +``` +Expected: All PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Command/Pipelines/PipelinesMigrateGitlabCommand.php \ + tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +git commit -m "feat: implement services and variables migration" +``` + +--- + +## Task 5: Events conversion (stages + jobs) + +**Files:** +- Modify: `src/Command/Pipelines/PipelinesMigrateGitlabCommand.php` +- Modify: `tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php` + +**Interfaces:** +- Consumes: `$acquiaPipelinesContents['events']`, `$servicesOutput['_composer']` from `migrateServices()` +- Produces: Private method `migrateEvents(array $contents, bool $hasComposer): array` returning the full set of stage names and job definitions for the GitLab CI file. The `convert()` method is updated to call this and merge the results. + +- [ ] **Step 1: Write failing tests for events conversion** + +Add to `PipelinesMigrateGitlabCommandTest`: + +```php +#[Group('serial')] +public function testFullConversionAllEvents(): void +{ + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $contents = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + + // Stages. + $this->assertSame( + ['build', 'fail-on-build', 'post-deploy', 'pr-merged', 'pr-closed'], + $contents['stages'] + ); + + // Build jobs exist and have correct stage. + $this->assertArrayHasKey('setup', $contents); + $this->assertSame('build', $contents['setup']['stage']); + $this->assertArrayHasKey('script', $contents['setup']); + + // Composer install added as before_script on build jobs. + $this->assertArrayHasKey('before_script', $contents['setup']); + $this->assertContains('composer install', $contents['setup']['before_script']); + + // fail-on-build jobs have when: on_failure. + $this->assertArrayHasKey('notify', $contents); + $this->assertSame('fail-on-build', $contents['notify']['stage']); + $this->assertSame('on_failure', $contents['notify']['when']); + + // pr-merged jobs have rules. + $this->assertArrayHasKey('cleanup', $contents); + $this->assertSame('pr-merged', $contents['cleanup']['stage']); + $this->assertArrayHasKey('rules', $contents['cleanup']); + + // pr-closed jobs have rules. + $this->assertArrayHasKey('teardown', $contents); + $this->assertSame('pr-closed', $contents['teardown']['stage']); + $this->assertArrayHasKey('rules', $contents['teardown']); + + // Source file not deleted. + $this->assertFileExists(Path::join($this->projectDir, 'acquia-pipelines.yml')); +} + +#[Group('serial')] +public function testFailOnBuildJobsHaveWhenOnFailure(): void +{ + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $contents = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertSame('on_failure', $contents['notify']['when']); + $this->assertSame('fail-on-build', $contents['notify']['stage']); +} + +#[Group('serial')] +public function testPrMergedAndPrClosedRules(): void +{ + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $contents = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + + $this->assertArrayHasKey('rules', $contents['cleanup']); + $this->assertSame('$CI_PIPELINE_SOURCE == "merge_request_event"', $contents['cleanup']['rules'][0]['if']); + $this->assertSame('on_success', $contents['cleanup']['rules'][0]['when']); + + $this->assertArrayHasKey('rules', $contents['teardown']); + $this->assertSame('$CI_PIPELINE_SOURCE == "merge_request_event"', $contents['teardown']['rules'][0]['if']); + $this->assertSame('manual', $contents['teardown']['rules'][0]['when']); +} + +#[Group('serial')] +public function testStepWithEmptyScriptIsSkipped(): void +{ + $fs = new Filesystem(); + $customContent = "version: 1.0\nevents:\n build:\n steps:\n - empty-step:\n type: script\n script: []\n - real-step:\n type: script\n script:\n - echo hi\n"; + $fs->dumpFile(Path::join($this->projectDir, 'acquia-pipelines.yml'), $customContent); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $contents = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayNotHasKey('empty-step', $contents); + $this->assertArrayHasKey('real-step', $contents); +} + +#[Group('serial')] +public function testMissingEventsKeyThrows(): void +{ + $fs = new Filesystem(); + $fs->dumpFile( + Path::join($this->projectDir, 'acquia-pipelines.yml'), + "version: 1.0\nservices:\n - php:\n version: '8.1'\n" + ); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessage("does not contain an 'events' key"); + $this->executeCommand(); +} + +#[Group('serial')] +public function testSourceFileIsNotDeleted(): void +{ + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $this->assertFileExists(Path::join($this->projectDir, 'acquia-pipelines.yml')); +} + +#[Group('serial')] +public function testExistingOutputFileIsOverwritten(): void +{ + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + $fs->dumpFile(Path::join($this->projectDir, '.gitlab-ci.yml'), 'old: content'); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $contents = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('stages', $contents); + $this->assertStringContainsString('overwritten', $this->getDisplay()); +} +``` + +- [ ] **Step 2: Run the new tests — confirm they fail** + +```bash +vendor/bin/phpunit --filter "testFullConversionAllEvents|testFailOnBuildJobsHaveWhenOnFailure|testPrMergedAndPrClosedRules|testStepWithEmptyScriptIsSkipped|testMissingEventsKeyThrows|testSourceFileIsNotDeleted|testExistingOutputFileIsOverwritten" tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +``` +Expected: All FAIL. + +- [ ] **Step 3: Implement events migration** + +Replace the `convert()` method and add `migrateEvents()`. The full updated command file: + +```php + [ + 'stage' => 'build', + 'when' => null, + 'rules' => null, + ], + 'fail-on-build' => [ + 'stage' => 'fail-on-build', + 'when' => 'on_failure', + 'rules' => null, + ], + 'post-deploy' => [ + 'stage' => 'post-deploy', + 'when' => null, + 'rules' => null, + ], + 'pr-merged' => [ + 'stage' => 'pr-merged', + 'when' => null, + 'rules' => [ + [ + 'if' => '$CI_PIPELINE_SOURCE == "merge_request_event"', + 'when' => 'on_success', + ], + ], + ], + 'pr-closed' => [ + 'stage' => 'pr-closed', + 'when' => null, + 'rules' => [ + [ + 'if' => '$CI_PIPELINE_SOURCE == "merge_request_event"', + 'when' => 'manual', + ], + ], + ], + ]; + // phpcs:enable + + protected function configure(): void + { + $this->addOption('path', null, InputOption::VALUE_REQUIRED, 'Path to the directory containing the acquia-pipelines.yml file. Defaults to the current directory.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $sourceFile = $this->resolveSourceFile($input); + $acquiaPipelinesContents = $this->parseSourceFile($sourceFile['path']); + $gitlabCiContents = $this->convert($acquiaPipelinesContents); + $outputPath = Path::join(dirname($sourceFile['path']), '.gitlab-ci.' . $sourceFile['extension']); + + if ($this->localMachineHelper->getFilesystem()->exists($outputPath)) { + $this->io->warning("Existing $outputPath was overwritten."); + } + + $this->localMachineHelper->getFilesystem()->dumpFile( + $outputPath, + Yaml::dump($gitlabCiContents, 4, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK) + ); + + $this->io->success("Migration complete. Created $outputPath. Review the file before committing — some manual adjustments may be needed."); + + return Command::SUCCESS; + } + + /** + * @return array{path: string, extension: string} + */ + private function resolveSourceFile(InputInterface $input): array + { + $dir = $input->getOption('path') ?? $this->projectDir; + + if (!$this->localMachineHelper->getFilesystem()->exists($dir)) { + throw new AcquiaCliException("The path '{$dir}' does not exist."); + } + + foreach (['yml', 'yaml'] as $extension) { + $candidate = Path::join($dir, "acquia-pipelines.$extension"); + if ($this->localMachineHelper->getFilesystem()->exists($candidate)) { + return ['path' => $candidate, 'extension' => $extension]; + } + } + + throw new AcquiaCliException("No acquia-pipelines.yml or acquia-pipelines.yaml file found in {$dir}."); + } + + /** + * @return array + */ + private function parseSourceFile(string $path): array + { + $raw = file_get_contents($path); + if ($raw === false || trim($raw) === '') { + throw new AcquiaCliException("The file {$path} is empty or unreadable."); + } + try { + $parsed = Yaml::parse($raw); + } catch (ParseException $e) { + throw new AcquiaCliException("Failed to parse {$path}: " . $e->getMessage()); + } + if (!is_array($parsed) || !array_key_exists('events', $parsed)) { + throw new AcquiaCliException("The file {$path} does not contain an 'events' key."); + } + return $parsed; + } + + /** + * @param array $acquiaPipelinesContents + * @return array + */ + private function convert(array $acquiaPipelinesContents): array + { + $gitlabCi = []; + + $servicesOutput = $this->migrateServices($acquiaPipelinesContents); + if (isset($servicesOutput['image'])) { + $gitlabCi['image'] = $servicesOutput['image']; + } + if (isset($servicesOutput['services'])) { + $gitlabCi['services'] = $servicesOutput['services']; + } + + $variables = $this->migrateVariables($acquiaPipelinesContents); + if (!empty($variables)) { + $gitlabCi['variables'] = $variables; + } + + $hasComposer = isset($servicesOutput['_composer']) && $servicesOutput['_composer']; + $eventsOutput = $this->migrateEvents($acquiaPipelinesContents, $hasComposer); + + $gitlabCi['stages'] = $eventsOutput['stages']; + unset($eventsOutput['stages']); + + return array_merge($gitlabCi, $eventsOutput); + } + + /** + * @param array $contents + * @return array + */ + private function migrateServices(array $contents): array + { + $output = []; + $composerFound = false; + + if (!array_key_exists('services', $contents)) { + return $output; + } + + foreach ($contents['services'] as $service) { + if (is_string($service)) { + $name = $service; + $version = null; + } else { + $name = array_key_first($service); + $version = $service[$name]['version'] ?? null; + } + + match ($name) { + 'php' => $output['image'] = 'php:' . $version, + 'mysql' => $output['services'][] = $version ? "mysql:$version" : 'mysql', + 'composer' => $composerFound = true, + default => $this->io->warning("Service '$name' is not supported and was skipped. Configure it manually in the generated .gitlab-ci file."), + }; + } + + if ($composerFound) { + $output['_composer'] = true; + } + + return $output; + } + + /** + * @param array $contents + * @return array + */ + private function migrateVariables(array $contents): array + { + if (!array_key_exists('variables', $contents)) { + return []; + } + + $vars = $contents['variables']; + + if (array_key_exists('global', $vars) && is_array($vars['global'])) { + return $vars['global']; + } + + return $vars; + } + + /** + * @param array $contents + * @return array + */ + private function migrateEvents(array $contents, bool $hasComposer): array + { + $stages = []; + $jobs = []; + + foreach (self::EVENT_STAGE_MAP as $eventName => $eventConfig) { + if (!array_key_exists($eventName, $contents['events'])) { + continue; + } + + $eventData = $contents['events'][$eventName]; + if (empty($eventData['steps'])) { + $this->io->warning("Event '$eventName' has no steps and was skipped."); + continue; + } + + $stages[] = $eventConfig['stage']; + + foreach ($eventData['steps'] as $step) { + $stepName = array_key_first($step); + $stepData = $step[$stepName]; + + if (empty($stepData['script'])) { + $this->io->warning("Step '$stepName' in event '$eventName' has no script and was skipped."); + continue; + } + + $job = ['stage' => $eventConfig['stage']]; + + if ($hasComposer && $eventName === 'build') { + $job['before_script'] = ['composer install']; + } + + $job['script'] = $stepData['script']; + + if ($eventConfig['when'] !== null) { + $job['when'] = $eventConfig['when']; + } + + if ($eventConfig['rules'] !== null) { + $job['rules'] = $eventConfig['rules']; + } + + $jobs[$stepName] = $job; + } + + $this->io->success("Migrated '$eventName' event."); + } + + return array_merge(['stages' => $stages], $jobs); + } +} +``` + +- [ ] **Step 4: Run all events tests — confirm they pass** + +```bash +vendor/bin/phpunit --filter "testFullConversionAllEvents|testFailOnBuildJobsHaveWhenOnFailure|testPrMergedAndPrClosedRules|testStepWithEmptyScriptIsSkipped|testMissingEventsKeyThrows|testSourceFileIsNotDeleted|testExistingOutputFileIsOverwritten" tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +``` +Expected: All PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/Command/Pipelines/PipelinesMigrateGitlabCommand.php \ + tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +git commit -m "feat: implement events migration with stages and job rules" +``` + +--- + +## Task 6: Full test suite run and cleanup + +**Files:** +- Modify: `tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php` (remove any duplicate test method if `testFullConversionAllEvents` was defined twice during TDD) + +**Interfaces:** +- Consumes: All prior tasks +- Produces: Green test suite for the new command; passing `composer test` output. + +- [ ] **Step 1: Run the full test class** + +```bash +vendor/bin/phpunit tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +``` +Expected: All 12 tests PASS with no errors or warnings. + +- [ ] **Step 2: Run phpcs on the new files** + +```bash +composer cs -- src/Command/Pipelines/PipelinesMigrateGitlabCommand.php \ + tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +``` +Expected: No violations. If there are `use` ordering issues, run `composer cbf` on those files. + +- [ ] **Step 3: Run PHPStan** + +```bash +composer stan -- --memory-limit=2G +``` +Expected: No errors. Fix any type issues (e.g. missing `@param`/`@return` docblocks PHPStan infers, `array_key_first` on non-empty array). + +- [ ] **Step 4: Run the full test suite** + +```bash +composer test +``` +Expected: All tests pass. + +- [ ] **Step 5: Final commit** + +```bash +git add src/Command/Pipelines/PipelinesMigrateGitlabCommand.php \ + tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php \ + tests/fixtures/acquia-pipelines-full.yml +git commit -m "feat: complete pipelines:migrate:gitlab command with full test coverage" +``` diff --git a/docs/superpowers/specs/2026-07-09-pipelines-migrate-gitlab-design.md b/docs/superpowers/specs/2026-07-09-pipelines-migrate-gitlab-design.md new file mode 100644 index 000000000..c4369d505 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-pipelines-migrate-gitlab-design.md @@ -0,0 +1,166 @@ +# Design: `pipelines:migrate:gitlab` Command + +**Date:** 2026-07-09 +**Branch:** PIPE-8085 +**Author:** Shubham Bansal + +## Overview + +A new standalone CLI command that converts an Acquia `acquia-pipelines.yml` (or `.yaml`) file into a generic `.gitlab-ci.yml` (or `.gitlab-ci.yaml`) file. Fully offline — no Acquia Cloud API calls, no GitLab authentication, no network required. The source file is not deleted after conversion. + +This is distinct from the existing `codestudio:pipelines-migrate` command, which converts to a Code Studio (Acquia's GitLab-based product) specific `.gitlab-ci.yml` with AutoDevOps template inclusion and requires GitLab + Cloud Platform auth. + +--- + +## Command Interface + +**Name:** `pipelines:migrate:gitlab` +**Alias:** `p:m:g` +**Location:** `src/Command/Pipelines/PipelinesMigrateGitlabCommand.php` + +**Options:** +- `--path` (optional, `VALUE_REQUIRED`) — path to the **directory** containing the acquia-pipelines file. The option itself is optional; if omitted, defaults to `$projectDir` (the CWD where acli is invoked, injected via Symfony DI). If provided, a value is required. + +**Usage examples:** +``` +acli pipelines:migrate:gitlab +acli pipelines:migrate:gitlab --path=/path/to/repo +acli p:m:g +``` + +--- + +## File Detection + +1. Resolve target directory: use `--path` if provided, otherwise `$projectDir`. +2. If `--path` is provided and the directory does not exist → throw `AcquiaCliException`: `"The path '' does not exist."` +3. Look for `acquia-pipelines.yml` first, then `acquia-pipelines.yaml`. +4. If neither exists → throw `AcquiaCliException`: `"No acquia-pipelines.yml or acquia-pipelines.yaml file found in ."` +5. Output filename mirrors input extension: + - `acquia-pipelines.yml` → `.gitlab-ci.yml` + - `acquia-pipelines.yaml` → `.gitlab-ci.yaml` +6. Output is written to the **same directory** as the input file. +7. If the output file already exists → overwrite it and emit `io->warning("Existing was overwritten.")`. + +--- + +## YAML Conversion Mapping + +### `services` block + +| Acquia service entry | GitLab CI output | +|---|---| +| `php: version: X` | Top-level `image: php:X` | +| `mysql` or `mysql: version: X` | Top-level `services: [mysql]` or `services: [mysql:X]` | +| `composer: version: X` | `before_script: [composer install]` added to every job in the `build` stage | +| Any other service | Skipped with `io->warning()` — user must configure manually | + +### `variables.global` + +Flattened to top-level `variables:` (the `global:` wrapper is stripped). Same approach as `CodeStudioPipelinesMigrateCommand::migrateVariablesSection()`. + +### `stages` declaration + +Only stages with actual content are included. Order is always: +```yaml +stages: + - build + - fail-on-build + - post-deploy + - pr-merged + - pr-closed +``` + +### Event → Stage mapping + +| Acquia event | GitLab stage | Special GitLab rule | +|---|---|---| +| `build` | `build` | None (runs on every pipeline) | +| `fail-on-build` | `fail-on-build` | `when: on_failure` on every job in this stage | +| `post-deploy` | `post-deploy` | None (runs always, after build) | +| `pr-merged` | `pr-merged` | `rules: [{if: '$CI_PIPELINE_SOURCE == "merge_request_event"', when: on_success}]` + YAML comment: `# TODO: Adjust rule — GitLab has no direct "merged" pipeline event. Consider using push pipelines on your default branch instead.` | +| `pr-closed` | `pr-closed` | `rules: [{if: '$CI_PIPELINE_SOURCE == "merge_request_event"', when: manual}]` + YAML comment: `# TODO: GitLab has no native pipeline trigger for a closed-without-merge MR. This is a best-effort placeholder — review and adjust manually.` | + +### Step → Job mapping + +Each step within an event becomes a named GitLab CI job: +```yaml +: + stage: + script: + - + - + # + when: on_failure (fail-on-build steps only) + # + rules: [...] (pr-merged / pr-closed steps only) + # + before_script: [composer install] (build steps only, if composer service present) +``` + +--- + +## Error Handling & Console Output + +### Errors — throw `AcquiaCliException`, halt execution + +- `--path` directory does not exist +- Input file not found in target directory +- Input file is empty or invalid YAML +- Input file has no `events` key + +### Warnings — `io->warning()`, continue + +- An event exists in the file but has no steps → skip that stage, warn user +- A step exists but has no `script` key or empty script → skip that job, warn user +- A `services` entry is not `php`, `mysql`, or `composer` → skip it, warn user to configure manually + +### Success messages — `io->success()` + +- `"Migrated 'variables' section."` (if variables present) +- `"Migrated '' event."` (one per successfully migrated event) +- Final: `"Migration complete. Created . Review the file before committing — some manual adjustments may be needed."` + +### Source file behavior + +The source `acquia-pipelines.yml`/`.yaml` file is **NOT deleted** after conversion. The user decides what to do with it. + +--- + +## Implementation Structure + +``` +src/Command/Pipelines/ + PipelinesMigrateGitlabCommand.php # the command class + +tests/phpunit/src/Commands/Pipelines/ + PipelinesMigrateGitlabCommandTest.php + +tests/fixtures/ + acquia-pipelines.yml # already exists, reused + acquia-pipelines.yaml # new fixture (tests .yaml extension mirroring) +``` + +No new trait. No shared code with `CodeStudioPipelinesMigrateCommand`. All conversion logic is private methods on the command class. + +The command is auto-discovered by Symfony DI via the existing `resource: ../../src/Command` glob in `config/prod/services.yml` — no manual registration needed. + +--- + +## Test Cases + +Test file: `tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php` + +Uses `#[DataProvider]`, `#[Group]` PHP attributes. Extends `CommandTestBase`. No API client mocking needed. + +| # | Scenario | Assertion | +|---|---|---| +| 1 | Full conversion — all 5 events, services, variables | Output file exists; stages, jobs, variables, image, services all correct | +| 2 | `--path` flag — custom directory | File found and output written to correct directory | +| 3 | `.yaml` extension input | Output file is `.gitlab-ci.yaml` not `.gitlab-ci.yml` | +| 4 | Missing input file | `AcquiaCliException` thrown | +| 5 | Missing `events` key | `AcquiaCliException` thrown | +| 6 | Step with empty `script` | Job skipped, no exception, warning emitted | +| 7 | Unknown service entry | Warning emitted, rest of conversion proceeds | +| 8 | `fail-on-build` steps | Each job has `when: on_failure` | +| 9 | `pr-merged` / `pr-closed` jobs | Correct `rules:` on each job | +| 10 | Source file not deleted | `acquia-pipelines.yml` still present after command runs | +| 11 | Output file already exists | Overwritten, warning emitted | +| 12 | `--path` points to non-existent directory | `AcquiaCliException` thrown | diff --git a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php new file mode 100644 index 000000000..522441658 --- /dev/null +++ b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php @@ -0,0 +1,319 @@ + [ + 'stage' => 'build', + 'when' => null, + 'rules' => null, + ], + 'fail-on-build' => [ + 'stage' => 'fail-on-build', + 'when' => 'on_failure', + 'rules' => null, + ], + 'post-deploy' => [ + 'stage' => 'post-deploy', + 'when' => null, + 'rules' => null, + ], + 'pr-merged' => [ + 'stage' => 'pr-merged', + 'when' => null, + 'rules' => [ + [ + 'if' => '$CI_PIPELINE_SOURCE == "merge_request_event"', + 'when' => 'on_success', + ], + ], + ], + 'pr-closed' => [ + 'stage' => 'pr-closed', + 'when' => null, + 'rules' => [ + [ + 'if' => '$CI_PIPELINE_SOURCE == "merge_request_event"', + 'when' => 'manual', + ], + ], + ], + ]; + // phpcs:enable SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys + + protected function configure(): void + { + $this->addOption('path', null, InputOption::VALUE_REQUIRED, 'Path to the directory containing the acquia-pipelines.yml file. Defaults to the current directory.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $sourceFile = $this->resolveSourceFile($input); + $acquiaPipelinesContents = $this->parseSourceFile($sourceFile['path']); + $gitlabCiContents = $this->convert($acquiaPipelinesContents); + $outputPath = Path::join(dirname($sourceFile['path']), '.gitlab-ci.' . $sourceFile['extension']); + + if ($this->localMachineHelper->getFilesystem()->exists($outputPath)) { + $this->io->warning("Existing $outputPath was overwritten."); + } + + // @infection-ignore-all Depth and indent values are arbitrary defaults; exact output format tested in tests. + $yamlString = Yaml::dump($gitlabCiContents, 4, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK); + $yamlString = $this->injectYamlComments($yamlString, $gitlabCiContents); + $this->localMachineHelper->getFilesystem()->dumpFile($outputPath, $yamlString); + + $this->io->success("Migration complete. Created $outputPath. Review the file before committing — some manual adjustments may be needed."); + + return Command::SUCCESS; + } + + /** + * @return array{path: string, extension: string} + */ + private function resolveSourceFile(InputInterface $input): array + { + $dir = $input->getOption('path') ?? $this->projectDir; + + if (!$this->localMachineHelper->getFilesystem()->exists($dir)) { + throw new AcquiaCliException("The path '{$dir}' does not exist."); + } + + foreach (['yml', 'yaml'] as $extension) { + $candidate = Path::join($dir, "acquia-pipelines.$extension"); + if ($this->localMachineHelper->getFilesystem()->exists($candidate)) { + return ['path' => $candidate, 'extension' => $extension]; + } + } + + throw new AcquiaCliException("No acquia-pipelines.yml or acquia-pipelines.yaml file found in {$dir}."); + } + + /** + * @return array + */ + private function parseSourceFile(string $path): array + { + $raw = file_get_contents($path); + if ($raw === false || trim($raw) === '') { + throw new AcquiaCliException("The file {$path} is empty or unreadable."); + } + try { + $parsed = Yaml::parse($raw); + } catch (ParseException $e) { + throw new AcquiaCliException("Failed to parse {$path}: " . $e->getMessage()); + } + if (!is_array($parsed) || !array_key_exists('events', $parsed)) { + throw new AcquiaCliException("The file {$path} does not contain an 'events' key."); + } + if (!is_array($parsed['events'])) { + throw new AcquiaCliException("The file {$path} has an 'events' key but its value is not a mapping."); + } + return $parsed; + } + + /** + * @param array $acquiaPipelinesContents + * @return array + */ + private function convert(array $acquiaPipelinesContents): array + { + $output = []; + $servicesMeta = $this->migrateServices($acquiaPipelinesContents); + $variables = $this->migrateVariables($acquiaPipelinesContents); + + if (isset($servicesMeta['image'])) { + $output['image'] = $servicesMeta['image']; + } + if (isset($servicesMeta['services'])) { + $output['services'] = $servicesMeta['services']; + } + if (!empty($variables)) { + $output['variables'] = $variables; + $this->io->success("Migrated 'variables' section."); + } + + $hasComposer = isset($servicesMeta['_composer']) && $servicesMeta['_composer']; + $eventsMeta = $this->migrateEvents($acquiaPipelinesContents, $hasComposer); + + if (!empty($eventsMeta['stages'])) { + $output['stages'] = $eventsMeta['stages']; + } + + foreach ($eventsMeta['jobs'] as $jobName => $jobDef) { + $output[$jobName] = $jobDef; + } + + return $output; + } + + /** + * @param array $contents + * @return array + */ + private function migrateServices(array $contents): array + { + $output = []; + + if (!array_key_exists('services', $contents)) { + // @infection-ignore-all Returning empty array; ArrayOneItem mutant is equivalent here. + return $output; + } + + foreach ($contents['services'] as $service) { + if (is_string($service)) { + $name = $service; + $version = null; + } elseif (is_array($service) && !empty($service)) { + // @infection-ignore-all (string) cast is defensive; keys are always strings in valid Acquia pipelines YAML. + $name = (string) array_key_first($service); + $version = is_array($service[$name]) ? ($service[$name]['version'] ?? null) : null; + } else { + $this->io->warning('Skipping malformed service entry. Configure it manually in .gitlab-ci.yml.'); + continue; + } + + match ($name) { + 'php' => $version !== null + ? $output['image'] = 'php:' . $version + : $this->io->warning("PHP service has no version specified. Configure 'image:' manually in .gitlab-ci.yml."), + 'mysql' => $output['services'][] = $version ? "mysql:$version" : 'mysql', + 'composer' => $output['_composer'] = true, + default => $this->io->warning("Unknown service '$name'. Configure it manually in .gitlab-ci.yml."), + }; + } + + return $output; + } + + /** + * @param array $contents + * @return array + */ + private function migrateVariables(array $contents): array + { + if (!array_key_exists('variables', $contents)) { + return []; + } + + $vars = $contents['variables']; + + if (array_key_exists('global', $vars) && is_array($vars['global'])) { + return $vars['global']; + } + + return $vars; + } + + /** + * @param array $contents + * @return array{stages: list, jobs: array>} + */ + private function migrateEvents(array $contents, bool $hasComposer): array + { + $stages = []; + $jobs = []; + + foreach (self::EVENT_STAGE_MAP as $eventName => $eventConfig) { + if (!array_key_exists($eventName, $contents['events'])) { + continue; + } + + $eventData = $contents['events'][$eventName]; + if (empty($eventData['steps'])) { + $this->io->warning("Event '$eventName' has no steps and was skipped."); + continue; + } + + $eventHasJob = false; + + foreach ($eventData['steps'] as $step) { + if (!is_array($step) || empty($step)) { + $this->io->warning("Malformed step in event '$eventName'. Skipping."); + continue; + } + // @infection-ignore-all (string) cast is defensive; step keys are always strings in valid Acquia pipelines YAML. + $stepName = (string) array_key_first($step); + $stepData = $step[$stepName]; + + if (!is_array($stepData) || empty($stepData['script'])) { + $this->io->warning("Step '$stepName' in event '$eventName' has no script. Skipping."); + continue; + } + + $job = [ + 'script' => $stepData['script'], + 'stage' => $eventConfig['stage'], + ]; + + if ($eventName === 'build' && $hasComposer) { + $job['before_script'] = ['composer install']; + } + if ($eventConfig['when'] !== null) { + $job['when'] = $eventConfig['when']; + } + if ($eventConfig['rules'] !== null) { + $job['rules'] = $eventConfig['rules']; + } + + if (array_key_exists($stepName, $jobs)) { + $this->io->warning("Step name '$stepName' appears in multiple events. The job from '$eventName' overwrites a previous one."); + } + $jobs[$stepName] = $job; + $eventHasJob = true; + } + + if ($eventHasJob) { + $stages[] = $eventConfig['stage']; + $this->io->success("Migrated '$eventName' event."); + } + } + + return ['stages' => $stages, 'jobs' => $jobs]; + } + + /** + * @param array $gitlabContents + */ + private function injectYamlComments(string $yaml, array $gitlabContents): string + { + $commentMap = [ + 'pr-closed' => '# TODO: GitLab has no native pipeline trigger for a closed-without-merge MR. This is a best-effort placeholder — review and adjust manually.', + 'pr-merged' => '# TODO: Adjust rule — GitLab has no direct "merged" pipeline event. Consider using push pipelines on your default branch instead.', + ]; + + foreach ($gitlabContents as $jobName => $jobDef) { + if (!is_array($jobDef) || !isset($jobDef['stage'])) { + continue; + } + $stage = $jobDef['stage']; + if (!isset($commentMap[$stage])) { + continue; + } + $comment = $commentMap[$stage]; + $yaml = preg_replace( + '/^(' . preg_quote((string) $jobName, '/') . ':)/m', + $comment . "\n" . '$1', + $yaml + ); + } + + return $yaml; + } +} diff --git a/tests/fixtures/acquia-pipelines-full.yml b/tests/fixtures/acquia-pipelines-full.yml new file mode 100644 index 000000000..91d683425 --- /dev/null +++ b/tests/fixtures/acquia-pipelines-full.yml @@ -0,0 +1,53 @@ +version: 1.3.0 +services: + - php: + version: '8.3' + - composer: + version: 2 + - mysql + +variables: + global: + SIMPLETEST_BASE_URL: "http://127.0.0.1:8080" + SIMPLETEST_DB: "mysql://root:root@localhost/drupal" + +events: + build: + steps: + - setup: + type: script + script: + - composer validate --no-check-all --ansi + - mysql -u root -proot -e "CREATE DATABASE IF NOT EXISTS drupal" + - validate: + type: script + script: + - composer drupal:validate + fail-on-build: + steps: + - notify: + type: script + script: + - echo "Build failed" + - curl -X POST https://hooks.example.com/notify + post-deploy: + steps: + - deploy: + type: script + script: + - echo "Deploying" + - pipelines-deploy + pr-merged: + steps: + - cleanup: + type: script + script: + - echo "PR merged" + - pipelines-deploy + pr-closed: + steps: + - teardown: + type: script + script: + - echo "PR closed" + - pipelines-deploy diff --git a/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index e0ad1a608..ebc71ace0 100644 --- a/tests/phpunit/src/Application/KernelTest.php +++ b/tests/phpunit/src/Application/KernelTest.php @@ -39,79 +39,81 @@ private function getStart(): string -v|vv|vvv, --verbose Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug Available commands: - completion Dump the shell completion script - docs Open Acquia product documentation in a web browser - help Display help for a command - list [self:list] List commands + completion Dump the shell completion script + docs Open Acquia product documentation in a web browser + help Display help for a command + list [self:list] List commands acsf - acsf:list [acsf] List all Acquia Cloud Site Factory commands + acsf:list [acsf] List all Acquia Cloud Site Factory commands api - api:list [api] List all API commands + api:list [api] List all API commands app - app:link [link] Associate your project with a Cloud Platform application - app:log:tail [tail|log:tail] Tail the logs from your environments - app:new:from:drupal7 [from:d7|ama] Generate a new Drupal 9+ project from a Drupal 7 application using the default Acquia Migrate Accelerate recommendations. - app:new:local [new] Create a new Drupal or Next.js project + app:link [link] Associate your project with a Cloud Platform application + app:log:tail [tail|log:tail] Tail the logs from your environments + app:new:from:drupal7 [from:d7|ama] Generate a new Drupal 9+ project from a Drupal 7 application using the default Acquia Migrate Accelerate recommendations. + app:new:local [new] Create a new Drupal or Next.js project EOD; } private function getEnd(): string { return <<injectCommand(PipelinesMigrateGitlabCommand::class); + } + + #[Group('serial')] + public function testFullConversionAllEvents(): void + { + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $outputFile = Path::join($this->projectDir, '.gitlab-ci.yml'); + $this->assertFileExists($outputFile); + $contents = Yaml::parseFile($outputFile); + + // Stages. + $this->assertSame( + ['build', 'fail-on-build', 'post-deploy', 'pr-merged', 'pr-closed'], + $contents['stages'] + ); + + // Image and services from fixture. + $this->assertSame('php:8.3', $contents['image']); + $this->assertContains('mysql', $contents['services']); + + // Variables section. + $this->assertArrayHasKey('variables', $contents); + $this->assertSame('http://127.0.0.1:8080', $contents['variables']['SIMPLETEST_BASE_URL']); + $this->assertSame('mysql://root:root@localhost/drupal', $contents['variables']['SIMPLETEST_DB']); + + // Build jobs exist and have correct stage. + $this->assertArrayHasKey('setup', $contents); + $this->assertSame('build', $contents['setup']['stage']); + $this->assertArrayHasKey('script', $contents['setup']); + + // Composer install added as before_script on build jobs. + $this->assertArrayHasKey('before_script', $contents['setup']); + $this->assertContains('composer install', $contents['setup']['before_script']); + + // fail-on-build jobs have when: on_failure. + $this->assertArrayHasKey('notify', $contents); + $this->assertSame('fail-on-build', $contents['notify']['stage']); + $this->assertSame('on_failure', $contents['notify']['when']); + + // pr-merged jobs have rules. + $this->assertArrayHasKey('cleanup', $contents); + $this->assertSame('pr-merged', $contents['cleanup']['stage']); + $this->assertArrayHasKey('rules', $contents['cleanup']); + $this->assertSame('$CI_PIPELINE_SOURCE == "merge_request_event"', $contents['cleanup']['rules'][0]['if']); + $this->assertSame('on_success', $contents['cleanup']['rules'][0]['when']); + + // pr-closed jobs have rules. + $this->assertArrayHasKey('teardown', $contents); + $this->assertSame('pr-closed', $contents['teardown']['stage']); + $this->assertArrayHasKey('rules', $contents['teardown']); + $this->assertSame('$CI_PIPELINE_SOURCE == "merge_request_event"', $contents['teardown']['rules'][0]['if']); + $this->assertSame('manual', $contents['teardown']['rules'][0]['when']); + + // Source file not deleted. + $this->assertFileExists(Path::join($this->projectDir, 'acquia-pipelines.yml')); + } + + public function testYmlExtensionIsDetected(): void + { + $content = file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertFileExists(Path::join($this->projectDir, '.gitlab-ci.yml')); + } + + public function testYamlExtensionIsDetected(): void + { + $content = file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yaml'), $content); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertFileExists(Path::join($this->projectDir, '.gitlab-ci.yaml')); + } + + public function testMissingInputFileThrows(): void + { + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessageMatches('/No acquia-pipelines\.yml or acquia-pipelines\.yaml file found/'); + $this->executeCommand(); + } + + public function testNonExistentPathOptionThrows(): void + { + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessageMatches('/does not exist/'); + $this->executeCommand(['--path' => '/nonexistent/path/abc123']); + } + + public function testSuccessMessageIsEmitted(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $this->assertStringContainsString('Migration complete', $this->getDisplay()); + } + + public function testServicesPhpMapsToImage(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertSame('php:8.3', $output['image']); + } + + public function testServicesMysqlMapsToServices(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertContains('mysql', $output['services']); + } + + public function testComposerServiceAddsBeforeScript(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + // 'setup' is in the build stage and fixture has composer service. + $this->assertArrayHasKey('before_script', $output['setup']); + $this->assertContains('composer install', $output['setup']['before_script']); + } + + public function testNoComposerServiceNoBeforeScript(): void + { + $yaml = "version: 1.0\nevents:\n build:\n steps:\n - myjob:\n script:\n - echo hi\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $yaml); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayNotHasKey('before_script', $output['myjob']); + } + + public function testVariablesGlobalIsFlattenedNonSerial(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('variables', $output); + $this->assertSame('http://127.0.0.1:8080', $output['variables']['SIMPLETEST_BASE_URL']); + $this->assertArrayNotHasKey('global', $output['variables']); + } + + public function testNoVariablesSectionProducesNoVariablesKey(): void + { + $yaml = "version: 1.0\nevents:\n build:\n steps:\n - myjob:\n script:\n - echo hi\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $yaml); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayNotHasKey('variables', $output); + } + + public function testVariablesWithoutGlobalWrapperPassedThrough(): void + { + $yaml = "version: 1.0\nvariables:\n MY_VAR: value\nevents:\n build:\n steps:\n - myjob:\n script:\n - echo hi\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $yaml); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('variables', $output); + $this->assertSame('value', $output['variables']['MY_VAR']); + } + + public function testStagesArePopulated(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('stages', $output); + $this->assertNotEmpty($output['stages']); + $this->assertContains('build', $output['stages']); + } + + public function testJobsArePopulated(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('setup', $output); + $this->assertSame('build', $output['setup']['stage']); + } + + public function testFailOnBuildJobHasWhenOnFailure(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertSame('on_failure', $output['notify']['when']); + } + + public function testPrMergedJobHasRules(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('rules', $output['cleanup']); + $this->assertSame('on_success', $output['cleanup']['rules'][0]['when']); + } + + public function testPrClosedJobHasRules(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('rules', $output['teardown']); + $this->assertSame('manual', $output['teardown']['rules'][0]['when']); + } + + public function testPrClosedTodoCommentIsInjected(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $raw = (string) file_get_contents(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertStringContainsString('# TODO: GitLab has no native pipeline trigger', $raw); + } + + public function testPrMergedTodoCommentIsInjected(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $raw = (string) file_get_contents(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertStringContainsString('# TODO: Adjust rule', $raw); + } + + public function testWhitespaceOnlyFileThrows(): void + { + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), " \n\t \n"); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessageMatches('/empty or unreadable/'); + $this->executeCommand(); + } + + public function testUnknownServiceWarningNonSerial(): void + { + $yaml = "version: 1.0\nservices:\n - redis\nevents:\n build:\n steps:\n - step1:\n script:\n - echo hi\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $yaml); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('redis', $this->getDisplay()); + } + + public function testEmptyInputFileThrowsNonSerial(): void + { + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), ''); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessageMatches('/empty or unreadable/'); + $this->executeCommand(); + } + + public function testInvalidYamlInputThrowsNonSerial(): void + { + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), "invalid: yaml: [\nbad"); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessageMatches('/Failed to parse/'); + $this->executeCommand(); + } + + public function testOverwriteWarningIsEmitted(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + file_put_contents(Path::join($this->projectDir, '.gitlab-ci.yml'), 'old: content'); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('overwritten', $this->getDisplay()); + } + + public function testInvalidYamlExceptionContainsBothMessageAndParseError(): void + { + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), "invalid: yaml: [\nbad"); + try { + $this->executeCommand(); + $this->fail('Expected AcquiaCliException was not thrown.'); + } catch (AcquiaCliException $e) { + // Must contain "Failed to parse" prefix. + $this->assertStringContainsString('Failed to parse', $e->getMessage()); + // Must start with "Failed to parse" (Concat mutant reverses the order). + $this->assertStringStartsWith('Failed to parse', $e->getMessage()); + // Message must be longer than "Failed to parse : " alone; $e->getMessage() adds parse details. + $this->assertGreaterThan(strlen('Failed to parse ' . Path::join($this->projectDir, 'acquia-pipelines.yml') . ': '), strlen($e->getMessage())); + } + } + + public function testEventsKeyWithScalarValueThrows(): void + { + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), "version: 1.0\nevents: \"not-a-mapping\"\n"); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessageMatches("/is not a mapping/"); + $this->executeCommand(); + } + + public function testPhpServiceWithNoVersionEmitsWarning(): void + { + $yaml = "version: 1.0\nservices:\n - php\nevents:\n build:\n steps:\n - myjob:\n script:\n - echo hi\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $yaml); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('no version specified', $this->getDisplay()); + // Without a version, 'image' must NOT be set to 'php:'. + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayNotHasKey('image', $output); + } + + public function testValidYamlWithoutEventsKeyThrows(): void + { + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), "version: 1.0\nservices:\n - php\n"); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessageMatches("/does not contain an 'events' key/"); + $this->executeCommand(); + } + + public function testVariablesMigrationMessageIsEmitted(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $this->assertStringContainsString("Migrated 'variables' section.", $this->getDisplay()); + } + + public function testIntegerServiceEntryIsSkippedWithWarningAndSubsequentServicesProcessed(): void + { + // YAML integer list item: 42 is not string and not array, should trigger warning path. + // The 'continue' (not 'break') must allow subsequent mysql service to still be processed. + $yaml = "version: 1.0\nservices:\n - 42\n - mysql\nevents:\n build:\n steps:\n - myjob:\n script:\n - echo hi\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $yaml); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('malformed', $this->getDisplay()); + // The subsequent mysql service must still be migrated (continue, not break). + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('services', $output); + $this->assertContains('mysql', $output['services']); + } + + public function testMultipleVariablesWithoutGlobalAreAllCopied(): void + { + $yaml = "version: 1.0\nvariables:\n VAR_A: alpha\n VAR_B: beta\nevents:\n build:\n steps:\n - myjob:\n script:\n - echo hi\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $yaml); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('variables', $output); + $this->assertSame('alpha', $output['variables']['VAR_A']); + $this->assertSame('beta', $output['variables']['VAR_B']); + } + + public function testEventsAfterMissingEventAreStillProcessed(): void + { + // Has build and post-deploy but NOT fail-on-build. The continue (not break) in the + // EVENT_STAGE_MAP loop means post-deploy must still be processed. + $yaml = "version: 1.0\nevents:\n build:\n steps:\n - setup:\n script:\n - echo build\n post-deploy:\n steps:\n - deploy:\n script:\n - echo deploy\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $yaml); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('deploy', $output); + $this->assertSame('post-deploy', $output['deploy']['stage']); + } + + public function testEventWithAllStepsSkippedAddsNoStage(): void + { + // Build event has a step with no script — all steps skipped, so the build stage + // should NOT be added to stages (eventHasJob remains false). + $yaml = "version: 1.0\nevents:\n build:\n steps:\n - empty-job:\n type: script\n script: []\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $yaml); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $stages = $output['stages'] ?? []; + $this->assertNotContains('build', $stages); + } + + public function testMalformedStepStringIsSkippedWithWarningNonSerial(): void + { + // A step that is a string (not an array) should be skipped with a warning. + // The continue (not break) must allow the subsequent real-step to still be processed. + $yaml = "version: 1.0\nevents:\n build:\n steps:\n - bad-string-step\n - real-step:\n script:\n - echo hi\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $yaml); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('Malformed step', $this->getDisplay()); + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('real-step', $output); + $this->assertArrayNotHasKey('bad-string-step', $output); + } + + public function testStepWithoutScriptKeyIsSkippedWithWarningNonSerial(): void + { + // A step with type but no script should be skipped with a warning. + $yaml = "version: 1.0\nevents:\n build:\n steps:\n - no-script:\n type: script\n - with-script:\n script:\n - echo hi\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $yaml); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('has no script', $this->getDisplay()); + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayNotHasKey('no-script', $output); + $this->assertArrayHasKey('with-script', $output); + } + + public function testJobsContainScriptProperty(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('script', $output['setup']); + $this->assertNotEmpty($output['setup']['script']); + } + + public function testDuplicateStepNameEmitsWarning(): void + { + // Two events with a step of the same name — duplicate warning should be emitted. + $yaml = "version: 1.0\nevents:\n build:\n steps:\n - shared-step:\n script:\n - echo build\n post-deploy:\n steps:\n - shared-step:\n script:\n - echo deploy\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $yaml); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('multiple events', $this->getDisplay()); + } + + public function testUniqueStepNamesProduceNoDuplicateWarning(): void + { + // No duplicate step names — the "multiple events" warning must NOT appear. + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringNotContainsString('multiple events', $this->getDisplay()); + } + + public function testEventMigrationMessageIsEmitted(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $this->assertStringContainsString("Migrated 'build' event.", $this->getDisplay()); + } + + public function testJobNameWithRegexSpecialCharsIsHandledByPregQuote(): void + { + // Step name containing '+' is a regex quantifier that would break preg_replace without preg_quote. + // Without preg_quote, '/^(step+name:)/m' means "one or more 'e' in 'step'" — wrong match. + // With preg_quote, '+' is escaped to '\+' so it matches the literal '+' in the step name. + $yaml = "version: 1.0\nevents:\n pr-closed:\n steps:\n - step+name:\n script:\n - echo closed\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $yaml); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $raw = (string) file_get_contents(Path::join($this->projectDir, '.gitlab-ci.yml')); + // The TODO comment must appear immediately before the job entry. + $this->assertMatchesRegularExpression('/# TODO: GitLab has no native pipeline trigger.*\nstep\+name:/s', $raw); + } + + public function testYamlIndentationIsTwoSpacesAndRulesAreBlockStyle(): void + { + $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $content); + + $this->executeCommand(); + + $raw = (string) file_get_contents(Path::join($this->projectDir, '.gitlab-ci.yml')); + // Two-space indentation: nested keys like "stage:" must be indented exactly 2 spaces. + $this->assertMatchesRegularExpression('/^ stage:/m', $raw); + // Must NOT use 1-space or 3-space indentation at the first level. + $this->assertDoesNotMatchRegularExpression('/^ stage:/m', $raw); + $this->assertDoesNotMatchRegularExpression('/^ stage:/m', $raw); + // Rules must be block style (depth >= 4) — not inlined as { if: ..., when: ... }. + $this->assertDoesNotMatchRegularExpression('/\{ if:/', $raw); + $this->assertStringContainsString('if:', $raw); + } + + #[Group('serial')] + public function testYmlExtensionInputProducesYmlOutput(): void + { + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertFileExists(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertFileDoesNotExist(Path::join($this->projectDir, '.gitlab-ci.yaml')); + } + + #[Group('serial')] + public function testYamlExtensionInputProducesYamlOutput(): void + { + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yaml') + ); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertFileExists(Path::join($this->projectDir, '.gitlab-ci.yaml')); + $this->assertFileDoesNotExist(Path::join($this->projectDir, '.gitlab-ci.yml')); + } + + #[Group('serial')] + public function testPathOption(): void + { + $tempDir = $this->getTempDir(); + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($tempDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(['--path' => $tempDir]); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertFileExists(Path::join($tempDir, '.gitlab-ci.yml')); + $fs->remove($tempDir); + } + + #[Group('serial')] + public function testServicesPhpMysqlComposer(): void + { + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $contents = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertSame('php:8.3', $contents['image']); + $this->assertContains('mysql', $contents['services']); + $this->assertArrayNotHasKey('_composer', $contents); + } + + #[Group('serial')] + public function testVariablesGlobalFlattened(): void + { + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $contents = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('variables', $contents); + $this->assertSame('http://127.0.0.1:8080', $contents['variables']['SIMPLETEST_BASE_URL']); + $this->assertSame('mysql://root:root@localhost/drupal', $contents['variables']['SIMPLETEST_DB']); + $this->assertArrayNotHasKey('global', $contents['variables']); + } + + #[Group('serial')] + public function testUnknownServiceWarning(): void + { + $customContent = "version: 1.0\nservices:\n - redis\nevents:\n build:\n steps:\n - step1:\n script:\n - echo hi\n"; + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), $customContent); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $this->assertStringContainsString('redis', $this->getDisplay()); + } + + #[Group('serial')] + public function testFailOnBuildJobsHaveWhenOnFailure(): void + { + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $contents = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertSame('on_failure', $contents['notify']['when']); + $this->assertSame('fail-on-build', $contents['notify']['stage']); + } + + #[Group('serial')] + public function testPrMergedAndPrClosedRules(): void + { + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $contents = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + + $this->assertArrayHasKey('rules', $contents['cleanup']); + $this->assertSame('$CI_PIPELINE_SOURCE == "merge_request_event"', $contents['cleanup']['rules'][0]['if']); + $this->assertSame('on_success', $contents['cleanup']['rules'][0]['when']); + + $this->assertArrayHasKey('rules', $contents['teardown']); + $this->assertSame('$CI_PIPELINE_SOURCE == "merge_request_event"', $contents['teardown']['rules'][0]['if']); + $this->assertSame('manual', $contents['teardown']['rules'][0]['when']); + } + + #[Group('serial')] + public function testStepWithEmptyScriptIsSkipped(): void + { + $fs = new Filesystem(); + $customContent = "version: 1.0\nevents:\n build:\n steps:\n - empty-step:\n type: script\n script: []\n - real-step:\n type: script\n script:\n - echo hi\n"; + $fs->dumpFile(Path::join($this->projectDir, 'acquia-pipelines.yml'), $customContent); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $contents = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayNotHasKey('empty-step', $contents); + $this->assertArrayHasKey('real-step', $contents); + } + + #[Group('serial')] + public function testMissingEventsKeyThrows(): void + { + $fs = new Filesystem(); + $fs->dumpFile( + Path::join($this->projectDir, 'acquia-pipelines.yml'), + "version: 1.0\nservices:\n - php:\n version: '8.1'\n" + ); + + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessageMatches("/does not contain an 'events' key/"); + $this->executeCommand(); + } + + #[Group('serial')] + public function testSourceFileIsNotDeleted(): void + { + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $this->assertFileExists(Path::join($this->projectDir, 'acquia-pipelines.yml')); + } + + #[Group('serial')] + public function testExistingOutputFileIsOverwritten(): void + { + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + $fs->dumpFile(Path::join($this->projectDir, '.gitlab-ci.yml'), 'old: content'); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $contents = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('stages', $contents); + $this->assertStringContainsString('overwritten', $this->getDisplay()); + } + + #[Group('serial')] + public function testPrMergedAndPrClosedJobsHaveYamlComments(): void + { + $fs = new Filesystem(); + $fs->copy( + Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml'), + Path::join($this->projectDir, 'acquia-pipelines.yml') + ); + + $this->executeCommand(); + + $this->assertSame(0, $this->getStatusCode()); + $outputFile = Path::join($this->projectDir, '.gitlab-ci.yml'); + $raw = file_get_contents($outputFile); + $this->assertStringContainsString('# TODO: Adjust rule', $raw); + $this->assertStringContainsString('# TODO: GitLab has no native pipeline trigger', $raw); + } + + #[Group('serial')] + public function testEmptyInputFileThrows(): void + { + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), ''); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessageMatches('/empty or unreadable/'); + $this->executeCommand(); + } + + #[Group('serial')] + public function testInvalidYamlInputThrows(): void + { + file_put_contents(Path::join($this->projectDir, 'acquia-pipelines.yml'), "invalid: yaml: [\nbad"); + $this->expectException(AcquiaCliException::class); + $this->expectExceptionMessageMatches('/Failed to parse/'); + $this->executeCommand(); + } +}