From 33df87b7f8467f0d9db5f2f7a47aebacaf88f365 Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 11:24:18 +0530 Subject: [PATCH 01/19] docs: add design spec for pipelines:migrate:gitlab command Co-Authored-By: Claude Sonnet 4.6 --- ...6-07-09-pipelines-migrate-gitlab-design.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-09-pipelines-migrate-gitlab-design.md 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 00000000..128e1a44 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-pipelines-migrate-gitlab-design.md @@ -0,0 +1,161 @@ +# 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. Look for `acquia-pipelines.yml` first, then `acquia-pipelines.yaml`. +3. If neither exists → throw `AcquiaCliException`: `"No acquia-pipelines.yml or acquia-pipelines.yaml file found in ."` +4. Output filename mirrors input extension: + - `acquia-pipelines.yml` → `.gitlab-ci.yml` + - `acquia-pipelines.yaml` → `.gitlab-ci.yaml` +5. Output is written to the **same directory** as the input file. + +--- + +## 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_MERGE_REQUEST_EVENT_TYPE == "merged_result"'}]` | +| `pr-closed` | `pr-closed` | `rules: [{if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "closed"'}]` | + +### 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 + +- 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 | From c3b38d280840f3cf743131162ea689a089f2bb47 Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 11:26:22 +0530 Subject: [PATCH 02/19] docs: fix pr-merged/pr-closed rules and add missing edge cases in spec Co-Authored-By: Claude Sonnet 4.6 --- ...026-07-09-pipelines-migrate-gitlab-design.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) 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 index 128e1a44..c4369d50 100644 --- a/docs/superpowers/specs/2026-07-09-pipelines-migrate-gitlab-design.md +++ b/docs/superpowers/specs/2026-07-09-pipelines-migrate-gitlab-design.md @@ -33,12 +33,14 @@ acli p:m:g ## File Detection 1. Resolve target directory: use `--path` if provided, otherwise `$projectDir`. -2. Look for `acquia-pipelines.yml` first, then `acquia-pipelines.yaml`. -3. If neither exists → throw `AcquiaCliException`: `"No acquia-pipelines.yml or acquia-pipelines.yaml file found in ."` -4. Output filename mirrors input extension: +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` -5. Output is written to the **same directory** as the input file. +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.")`. --- @@ -76,8 +78,8 @@ stages: | `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_MERGE_REQUEST_EVENT_TYPE == "merged_result"'}]` | -| `pr-closed` | `pr-closed` | `rules: [{if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_STATE == "closed"'}]` | +| `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 @@ -99,6 +101,7 @@ Each step within an event becomes a named GitLab CI job: ### 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 @@ -159,3 +162,5 @@ Uses `#[DataProvider]`, `#[Group]` PHP attributes. Extends `CommandTestBase`. No | 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 | From 1141a0ae403c3f070499f501832c6eef4279d026 Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 11:29:47 +0530 Subject: [PATCH 03/19] docs: add implementation plan for pipelines:migrate:gitlab command Co-Authored-By: Claude Sonnet 4.6 --- .../2026-07-09-pipelines-migrate-gitlab.md | 1110 +++++++++++++++++ 1 file changed, 1110 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-pipelines-migrate-gitlab.md 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 00000000..7632953c --- /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" +``` From 81e8f52c24c49775b20592f202853da89358d3b2 Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 11:32:45 +0530 Subject: [PATCH 04/19] PIPE-8085: Add fixture file with full Acquia Pipelines configuration Create tests/fixtures/acquia-pipelines-full.yml with all 5 pipeline events (build, fail-on-build, post-deploy, pr-merged, pr-closed) and full services and variables configuration. This fixture is used by subsequent pipeline migration tasks. --- tests/fixtures/acquia-pipelines-full.yml | 53 ++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 tests/fixtures/acquia-pipelines-full.yml diff --git a/tests/fixtures/acquia-pipelines-full.yml b/tests/fixtures/acquia-pipelines-full.yml new file mode 100644 index 00000000..91d68342 --- /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 From d788a1cc31f358fe42844344b44482ad55fe74b5 Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 11:37:59 +0530 Subject: [PATCH 05/19] feat: scaffold PipelinesMigrateGitlabCommand with failing test Co-Authored-By: Claude Sonnet 4.6 --- .../PipelinesMigrateGitlabCommand.php | 25 +++++++++++ .../PipelinesMigrateGitlabCommandTest.php | 41 +++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 src/Command/Pipelines/PipelinesMigrateGitlabCommand.php create mode 100644 tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php diff --git a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php new file mode 100644 index 00000000..8aa5a1df --- /dev/null +++ b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php @@ -0,0 +1,25 @@ +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; + } + +} diff --git a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php new file mode 100644 index 00000000..5c98dc73 --- /dev/null +++ b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php @@ -0,0 +1,41 @@ +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']); + } + +} From 2873bdb7502aca668e964d2f842c04b7db9747fe Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 11:39:48 +0530 Subject: [PATCH 06/19] style: fix phpcs violations in PipelinesMigrateGitlabCommand skeleton --- .../PipelinesMigrateGitlabCommand.php | 19 +++++----- .../PipelinesMigrateGitlabCommandTest.php | 38 +++++++++---------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php index 8aa5a1df..0a56141a 100644 --- a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php +++ b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php @@ -12,14 +12,15 @@ use Symfony\Component\Console\Output\OutputInterface; #[AsCommand(name: 'pipelines:migrate:gitlab', description: 'Convert an acquia-pipelines.yml file to a generic .gitlab-ci.yml file', aliases: ['p:m:g'])] -final class PipelinesMigrateGitlabCommand extends CommandBase { - - 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 { - return Command::SUCCESS; - } +final class PipelinesMigrateGitlabCommand extends CommandBase +{ + 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 + { + return Command::SUCCESS; + } } diff --git a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php index 5c98dc73..444be072 100644 --- a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +++ b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php @@ -12,30 +12,30 @@ use Symfony\Component\Filesystem\Path; use Symfony\Component\Yaml\Yaml; -class PipelinesMigrateGitlabCommandTest extends CommandTestBase { - - protected function createCommand(): CommandBase { - return $this->injectCommand(PipelinesMigrateGitlabCommand::class); - } - - // Smoke test — replaced by the full version in Task 5. +class PipelinesMigrateGitlabCommandTest extends CommandTestBase +{ + protected function createCommand(): CommandBase + { + return $this->injectCommand(PipelinesMigrateGitlabCommand::class); + } - #[Group('serial')] - public function testFullConversionAllEvents(): void { - $fs = new Filesystem(); - $fs->copy( + // 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->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']); + $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']); } - } From 5ff9aef0b9d801453e15254a020766febbda9b64 Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 11:50:42 +0530 Subject: [PATCH 07/19] feat: add file detection logic to PipelinesMigrateGitlabCommand --- .../PipelinesMigrateGitlabCommand.php | 71 +++++++++++++++++++ .../PipelinesMigrateGitlabCommandTest.php | 48 +++++++++++++ 2 files changed, 119 insertions(+) diff --git a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php index 0a56141a..40349156 100644 --- a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php +++ b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php @@ -5,11 +5,15 @@ namespace Acquia\Cli\Command\Pipelines; use Acquia\Cli\Command\CommandBase; +use Acquia\Cli\Exception\AcquiaCliException; 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\Filesystem\Path; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; #[AsCommand(name: 'pipelines:migrate:gitlab', description: 'Convert an acquia-pipelines.yml file to a generic .gitlab-ci.yml file', aliases: ['p:m:g'])] final class PipelinesMigrateGitlabCommand extends CommandBase @@ -21,6 +25,73 @@ protected function configure(): void 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']]; + } } diff --git a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php index 444be072..ae2fd9f5 100644 --- a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +++ b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php @@ -6,6 +6,7 @@ use Acquia\Cli\Command\CommandBase; use Acquia\Cli\Command\Pipelines\PipelinesMigrateGitlabCommand; +use Acquia\Cli\Exception\AcquiaCliException; use Acquia\Cli\Tests\CommandTestBase; use PHPUnit\Framework\Attributes\Group; use Symfony\Component\Filesystem\Filesystem; @@ -38,4 +39,51 @@ public function testFullConversionAllEvents(): void $this->assertArrayHasKey('stages', $contents); $this->assertContains('build', $contents['stages']); } + + 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); + } } From 35dc10159d3ce19da6d443edb43160c8c445d121 Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 11:57:28 +0530 Subject: [PATCH 08/19] feat: implement services and variables migration Co-Authored-By: Claude Sonnet 4.6 --- .../PipelinesMigrateGitlabCommand.php | 72 ++++++++++++++++++- .../PipelinesMigrateGitlabCommandTest.php | 49 +++++++++++++ 2 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php index 40349156..e4379faa 100644 --- a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php +++ b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php @@ -91,7 +91,75 @@ private function parseSourceFile(string $path): array */ private function convert(array $acquiaPipelinesContents): array { - // Stub — full implementation in Task 4. - return ['stages' => ['build']]; + $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."); + } + + // Stages and jobs — populated in Task 5. + $output['stages'] = ['build']; + + return $output; + } + + /** + * @param array $contents + * @return array + */ + private function migrateServices(array $contents): array + { + $output = []; + + if (!array_key_exists('services', $contents)) { + return $output; + } + + foreach ($contents['services'] as $service) { + if (is_string($service)) { + $name = $service; + $version = null; + } else { + $name = (string) array_key_first($service); + $version = $service[$name]['version'] ?? null; + } + + match ($name) { + 'php' => $output['image'] = 'php:' . $version, + '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; } } diff --git a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php index ae2fd9f5..a2c233ed 100644 --- a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +++ b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php @@ -86,4 +86,53 @@ public function testPathOption(): void $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()); + } } From 7132b621a27d9451054aa0ae3eb8a6b028f87bee Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 12:02:33 +0530 Subject: [PATCH 09/19] feat: implement events migration with stages and job rules - Add EVENT_STAGE_MAP constant mapping Acquia pipeline event names to GitLab CI stage names - Add migrateEvents() converting pipeline events to GitLab jobs with stage, script, before_script (composer), when (on_failure for fail-on-build), and rules (pr-merged/pr-closed MR conditions) - Update convert() to call migrateEvents() and merge stages + jobs into output - Expand test suite to 14 tests: full conversion, per-event assertions, empty script skip, source not deleted, overwrite behaviour Co-Authored-By: Claude Sonnet 4.6 --- .../PipelinesMigrateGitlabCommand.php | 96 +++++++++++- .../PipelinesMigrateGitlabCommandTest.php | 148 +++++++++++++++++- 2 files changed, 239 insertions(+), 5 deletions(-) diff --git a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php index e4379faa..75c84104 100644 --- a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php +++ b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php @@ -18,6 +18,16 @@ #[AsCommand(name: 'pipelines:migrate:gitlab', description: 'Convert an acquia-pipelines.yml file to a generic .gitlab-ci.yml file', aliases: ['p:m:g'])] final class PipelinesMigrateGitlabCommand extends CommandBase { + // phpcs:disable SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys + private const EVENT_STAGE_MAP = [ + 'build' => 'build', + 'fail-on-build' => 'fail-on-build', + 'post-deploy' => 'post-deploy', + 'pr-merged' => 'pr-merged', + 'pr-closed' => 'pr-closed', + ]; + // 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.'); @@ -106,8 +116,16 @@ private function convert(array $acquiaPipelinesContents): array $this->io->success("Migrated 'variables' section."); } - // Stages and jobs — populated in Task 5. - $output['stages'] = ['build']; + $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; } @@ -162,4 +180,78 @@ private function migrateVariables(array $contents): array 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 => $stageName) { + 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) { + $stepName = (string) array_key_first($step); + $stepData = $step[$stepName]; + + if (empty($stepData['script'])) { + $this->io->warning("Step '$stepName' in event '$eventName' has no script. Skipping."); + continue; + } + + $job = ['stage' => $stageName]; + + if ($hasComposer && $eventName === 'build') { + $job['before_script'] = ['composer install']; + } + + $job['script'] = $stepData['script']; + + if ($eventName === 'fail-on-build') { + $job['when'] = 'on_failure'; + } + + if ($eventName === 'pr-merged') { + $job['rules'] = [ + [ + 'if' => '$CI_PIPELINE_SOURCE == "merge_request_event"', + 'when' => 'on_success', + ], + ]; + } + + if ($eventName === 'pr-closed') { + $job['rules'] = [ + [ + 'if' => '$CI_PIPELINE_SOURCE == "merge_request_event"', + 'when' => 'manual', + ], + ]; + } + + $jobs[$stepName] = $job; + $eventHasJob = true; + } + + if ($eventHasJob) { + $stages[] = $stageName; + $this->io->success("Migrated '$eventName' event."); + } + } + + return ['stages' => $stages, 'jobs' => $jobs]; + } } diff --git a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php index a2c233ed..3f2c78d5 100644 --- a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +++ b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php @@ -20,7 +20,6 @@ protected function createCommand(): CommandBase return $this->injectCommand(PipelinesMigrateGitlabCommand::class); } - // Smoke test — replaced by the full version in Task 5. #[Group('serial')] public function testFullConversionAllEvents(): void { @@ -36,8 +35,52 @@ public function testFullConversionAllEvents(): void $outputFile = Path::join($this->projectDir, '.gitlab-ci.yml'); $this->assertFileExists($outputFile); $contents = Yaml::parseFile($outputFile); - $this->assertArrayHasKey('stages', $contents); - $this->assertContains('build', $contents['stages']); + + // 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 testMissingInputFileThrows(): void @@ -135,4 +178,103 @@ public function testUnknownServiceWarning(): void $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->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()); + } } From 67064d1d2c1dc982f4267bc5f56414b53478afde Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 12:07:42 +0530 Subject: [PATCH 10/19] refactor: make EVENT_STAGE_MAP data-driven with when and rules config Co-Authored-By: Claude Sonnet 4.6 --- .../PipelinesMigrateGitlabCommand.php | 77 +++++++++++-------- 1 file changed, 46 insertions(+), 31 deletions(-) diff --git a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php index 75c84104..7d60af25 100644 --- a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php +++ b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php @@ -20,11 +20,41 @@ final class PipelinesMigrateGitlabCommand extends CommandBase { // phpcs:disable SlevomatCodingStandard.Arrays.AlphabeticallySortedByKeys private const EVENT_STAGE_MAP = [ - 'build' => 'build', - 'fail-on-build' => 'fail-on-build', - 'post-deploy' => 'post-deploy', - 'pr-merged' => 'pr-merged', - 'pr-closed' => 'pr-closed', + 'build' => [ + '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 @@ -190,7 +220,7 @@ private function migrateEvents(array $contents, bool $hasComposer): array $stages = []; $jobs = []; - foreach (self::EVENT_STAGE_MAP as $eventName => $stageName) { + foreach (self::EVENT_STAGE_MAP as $eventName => $eventConfig) { if (!array_key_exists($eventName, $contents['events'])) { continue; } @@ -212,34 +242,19 @@ private function migrateEvents(array $contents, bool $hasComposer): array continue; } - $job = ['stage' => $stageName]; + $job = [ + 'script' => $stepData['script'], + 'stage' => $eventConfig['stage'], + ]; - if ($hasComposer && $eventName === 'build') { + if ($eventName === 'build' && $hasComposer) { $job['before_script'] = ['composer install']; } - - $job['script'] = $stepData['script']; - - if ($eventName === 'fail-on-build') { - $job['when'] = 'on_failure'; + if ($eventConfig['when'] !== null) { + $job['when'] = $eventConfig['when']; } - - if ($eventName === 'pr-merged') { - $job['rules'] = [ - [ - 'if' => '$CI_PIPELINE_SOURCE == "merge_request_event"', - 'when' => 'on_success', - ], - ]; - } - - if ($eventName === 'pr-closed') { - $job['rules'] = [ - [ - 'if' => '$CI_PIPELINE_SOURCE == "merge_request_event"', - 'when' => 'manual', - ], - ]; + if ($eventConfig['rules'] !== null) { + $job['rules'] = $eventConfig['rules']; } $jobs[$stepName] = $job; @@ -247,7 +262,7 @@ private function migrateEvents(array $contents, bool $hasComposer): array } if ($eventHasJob) { - $stages[] = $stageName; + $stages[] = $eventConfig['stage']; $this->io->success("Migrated '$eventName' event."); } } From b6e966290535d9bce621b30b989c38314a008d01 Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 12:15:59 +0530 Subject: [PATCH 11/19] chore: fix PHPStan issues and cleanup Update KernelTest column widths after adding pipelines:migrate:gitlab command (24-char name widens CLI list column from 25 to 26). Adds the pipelines section to the expected list output. Co-Authored-By: Claude Sonnet 4.6 --- tests/phpunit/src/Application/KernelTest.php | 110 ++++++++++--------- 1 file changed, 56 insertions(+), 54 deletions(-) diff --git a/tests/phpunit/src/Application/KernelTest.php b/tests/phpunit/src/Application/KernelTest.php index e0ad1a60..ebc71ace 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 << Date: Thu, 9 Jul 2026 12:25:46 +0530 Subject: [PATCH 12/19] fix: add YAML TODO comments for pr events, improve edge case handling - Inject TODO comments before pr-merged and pr-closed job names in the generated .gitlab-ci.yml via post-processing after Yaml::dump() - Warn when a step name collision occurs across events in migrateEvents() - Add tests: testEmptyInputFileThrows, testInvalidYamlInputThrows, testPrMergedAndPrClosedJobsHaveYamlComments Co-Authored-By: Claude Sonnet 4.6 --- .../PipelinesMigrateGitlabCommand.php | 39 +++++++++++++++++-- .../PipelinesMigrateGitlabCommandTest.php | 36 +++++++++++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php index 7d60af25..d9cd98dc 100644 --- a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php +++ b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php @@ -74,10 +74,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->warning("Existing $outputPath was overwritten."); } - $this->localMachineHelper->getFilesystem()->dumpFile( - $outputPath, - Yaml::dump($gitlabCiContents, 4, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK) - ); + $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."); @@ -257,6 +256,9 @@ private function migrateEvents(array $contents, bool $hasComposer): array $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; } @@ -269,4 +271,33 @@ private function migrateEvents(array $contents, bool $hasComposer): array 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/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php index 3f2c78d5..65d69b1a 100644 --- a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +++ b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php @@ -277,4 +277,40 @@ public function testExistingOutputFileIsOverwritten(): void $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(); + } } From de42fc8411fb0346b6dc2c68a21f9731a7781344 Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 15:05:03 +0530 Subject: [PATCH 13/19] fix: address Copilot review comments and fix mutation test coverage - Switch expectExceptionMessage() to expectExceptionMessageMatches() for substring assertions (testMissingInputFileThrows, testNonExistentPathOptionThrows, testMissingEventsKeyThrows) - Add testYmlExtensionInputProducesYmlOutput to kill the two escaped mutants (ArrayItemRemoval and Foreach_ on the yml/yaml loop) - Guard malformed service entries (non-array value) in migrateServices() - Guard malformed step entries (non-array or non-array stepData) in migrateEvents() before accessing array_key_first() Co-Authored-By: Claude Sonnet 4.6 --- .../PipelinesMigrateGitlabCommand.php | 13 ++++++++--- .../PipelinesMigrateGitlabCommandTest.php | 22 ++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php index d9cd98dc..d76d8975 100644 --- a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php +++ b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php @@ -175,9 +175,12 @@ private function migrateServices(array $contents): array if (is_string($service)) { $name = $service; $version = null; - } else { + } elseif (is_array($service) && !empty($service)) { $name = (string) array_key_first($service); - $version = $service[$name]['version'] ?? null; + $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) { @@ -233,10 +236,14 @@ private function migrateEvents(array $contents, bool $hasComposer): array $eventHasJob = false; foreach ($eventData['steps'] as $step) { + if (!is_array($step) || empty($step)) { + $this->io->warning("Malformed step in event '$eventName'. Skipping."); + continue; + } $stepName = (string) array_key_first($step); $stepData = $step[$stepName]; - if (empty($stepData['script'])) { + if (!is_array($stepData) || empty($stepData['script'])) { $this->io->warning("Step '$stepName' in event '$eventName' has no script. Skipping."); continue; } diff --git a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php index 65d69b1a..48b8ef4f 100644 --- a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +++ b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php @@ -86,17 +86,33 @@ public function testFullConversionAllEvents(): void public function testMissingInputFileThrows(): void { $this->expectException(AcquiaCliException::class); - $this->expectExceptionMessage('No acquia-pipelines.yml or acquia-pipelines.yaml file found'); + $this->expectExceptionMessageMatches('/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->expectExceptionMessageMatches('/does not exist/'); $this->executeCommand(['--path' => '/nonexistent/path/abc123']); } + #[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 { @@ -242,7 +258,7 @@ public function testMissingEventsKeyThrows(): void ); $this->expectException(AcquiaCliException::class); - $this->expectExceptionMessage("does not contain an 'events' key"); + $this->expectExceptionMessageMatches("/does not contain an 'events' key/"); $this->executeCommand(); } From d2d1b53ff6a2f3da28b434fda60c673b605842ef Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 15:25:19 +0530 Subject: [PATCH 14/19] test: add non-serial tests for yml/yaml extension detection Infection excludes the serial group (testFrameworkOptions: --exclude-group=serial), so the two escaped mutants on the foreach(['yml','yaml']) loop weren't caught. Add testYmlExtensionIsDetected and testYamlExtensionIsDetected as non-serial tests using file_put_contents into the vfsStream projectDir so Infection can exercise and kill both the ArrayItemRemoval and Foreach_ mutants on that line. Co-Authored-By: Claude Sonnet 4.6 --- .../PipelinesMigrateGitlabCommandTest.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php index 48b8ef4f..27655967 100644 --- a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +++ b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php @@ -83,6 +83,28 @@ public function testFullConversionAllEvents(): void $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); From 913d1c362ccca115c7ba6dab9d8542de0402a533 Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 15:52:29 +0530 Subject: [PATCH 15/19] test: add non-serial behavioral tests to achieve 100% covered MSI Add 20 new non-serial tests that assert conversion behavior using vfsStream, so Infection can cover and kill mutants on the command logic without needing serial tests excluded by --exclude-group=serial. Co-Authored-By: Claude Sonnet 4.6 --- .../PipelinesMigrateGitlabCommandTest.php | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) diff --git a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php index 27655967..98408fb7 100644 --- a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +++ b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php @@ -119,6 +119,233 @@ public function testNonExistentPathOptionThrows(): void $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 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 { From b224863901135f0cf7e7834a80b411dbfcb66a2d Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 16:42:36 +0530 Subject: [PATCH 16/19] test: add more non-serial tests to kill remaining escaped mutants Cover: overwrite warning, YAML parse error message format, missing events key, variables migration message, malformed services, multiple non-global variables, continue vs break in event loop, eventHasJob false initialization, malformed/missing steps, script property presence, duplicate step name warning, event migration message, preg_quote for step names with dots, and the YAML indentation/block-style assertions. Co-Authored-By: Claude Sonnet 4.6 --- .../PipelinesMigrateGitlabCommandTest.php | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) diff --git a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php index 98408fb7..7754a756 100644 --- a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +++ b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php @@ -328,6 +328,178 @@ public function testInvalidYamlInputThrowsNonSerial(): void $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) { + $this->assertStringContainsString('Failed to parse', $e->getMessage()); + // Message must start with "Failed to parse" (not be reversed by Concat mutant). + $this->assertStringStartsWith('Failed to parse', $e->getMessage()); + } + } + + 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 testIntegerServiceEntryIsSkippedWithWarning(): void + { + // YAML integer list item: 42 is not string and not array, should trigger warning path. + $yaml = "version: 1.0\nservices:\n - 42\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()); + } + + 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 testMalformedStepStringIsSkippedNonSerial(): void + { + // A step that is a string (not an array) should be skipped with a warning. + $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()); + $output = Yaml::parseFile(Path::join($this->projectDir, '.gitlab-ci.yml')); + $this->assertArrayHasKey('real-step', $output); + $this->assertArrayNotHasKey('bad-string-step', $output); + } + + public function testStepWithoutScriptKeyIsSkippedNonSerial(): void + { + // A step with type but no script should be skipped. + $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()); + $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 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 testJobNameWithDotIsHandledByPregQuote(): void + { + // Step name containing '.' would break preg_replace without preg_quote. + // With a pr-closed event, the TODO comment injection uses preg_replace with the job name. + $yaml = "version: 1.0\nevents:\n pr-closed:\n steps:\n - step.name.with.dots:\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')); + $this->assertStringContainsString('# TODO: GitLab has no native pipeline trigger', $raw); + } + public function testYamlIndentationIsTwoSpacesAndRulesAreBlockStyle(): void { $content = (string) file_get_contents(Path::join($this->realFixtureDir, 'acquia-pipelines-full.yml')); From 37a81dc4db230c294800a5592ba0a840f3598a00 Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 16:44:49 +0530 Subject: [PATCH 17/19] fix: add @infection-ignore-all for equivalent/harmless mutants Mark the Yaml::dump numeric arguments and (string) array_key_first() casts as infection-ignore-all since the mutations produce equivalent behavior for valid YAML input. Also annotate the empty-array early return in migrateServices() for the same reason. Co-Authored-By: Claude Sonnet 4.6 --- src/Command/Pipelines/PipelinesMigrateGitlabCommand.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php index d76d8975..4b49ea77 100644 --- a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php +++ b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php @@ -74,6 +74,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $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); @@ -168,6 +169,7 @@ 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; } @@ -176,6 +178,7 @@ private function migrateServices(array $contents): array $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 { @@ -240,6 +243,7 @@ private function migrateEvents(array $contents, bool $hasComposer): array $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]; From c2d76504bf0ccbc5baca2fcdf275d6392c9f363b Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 16:54:09 +0530 Subject: [PATCH 18/19] test: kill remaining 6 escaped mutants in PipelinesMigrateGitlabCommand - ConcatOperandRemoval: assert exception message length > prefix length - Continue_ (malformed service): assert subsequent service still migrated - MethodCallRemoval (malformed step): assert warning text appears - MethodCallRemoval (no-script step): assert warning text appears - IfNegation (duplicate step check): add negative test for no-duplicate case - PregQuote: use step name with '+' (regex special char); assert TODO comment appears directly before the job entry in raw YAML output Co-Authored-By: Claude Sonnet 4.6 --- .../PipelinesMigrateGitlabCommandTest.php | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php index 7754a756..25206fdf 100644 --- a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +++ b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php @@ -347,9 +347,12 @@ public function testInvalidYamlExceptionContainsBothMessageAndParseError(): void $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()); - // Message must start with "Failed to parse" (not be reversed by Concat mutant). + // 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())); } } @@ -371,16 +374,21 @@ public function testVariablesMigrationMessageIsEmitted(): void $this->assertStringContainsString("Migrated 'variables' section.", $this->getDisplay()); } - public function testIntegerServiceEntryIsSkippedWithWarning(): void + public function testIntegerServiceEntryIsSkippedWithWarningAndSubsequentServicesProcessed(): void { // YAML integer list item: 42 is not string and not array, should trigger warning path. - $yaml = "version: 1.0\nservices:\n - 42\nevents:\n build:\n steps:\n - myjob:\n script:\n - echo hi\n"; + // 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 @@ -424,29 +432,32 @@ public function testEventWithAllStepsSkippedAddsNoStage(): void $this->assertNotContains('build', $stages); } - public function testMalformedStepStringIsSkippedNonSerial(): void + 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 testStepWithoutScriptKeyIsSkippedNonSerial(): void + public function testStepWithoutScriptKeyIsSkippedWithWarningNonSerial(): void { - // A step with type but no script should be skipped. + // 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); @@ -476,6 +487,18 @@ public function testDuplicateStepNameEmitsWarning(): void $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')); @@ -486,18 +509,20 @@ public function testEventMigrationMessageIsEmitted(): void $this->assertStringContainsString("Migrated 'build' event.", $this->getDisplay()); } - public function testJobNameWithDotIsHandledByPregQuote(): void + public function testJobNameWithRegexSpecialCharsIsHandledByPregQuote(): void { - // Step name containing '.' would break preg_replace without preg_quote. - // With a pr-closed event, the TODO comment injection uses preg_replace with the job name. - $yaml = "version: 1.0\nevents:\n pr-closed:\n steps:\n - step.name.with.dots:\n script:\n - echo closed\n"; + // 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')); - $this->assertStringContainsString('# TODO: GitLab has no native pipeline trigger', $raw); + // 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 From df8f7f0213f8e4d9bf34df801299357fcc1b2897 Mon Sep 17 00:00:00 2001 From: Shubham Bansal Date: Thu, 9 Jul 2026 17:30:34 +0530 Subject: [PATCH 19/19] fix: address latest Copilot review comments - parseSourceFile: validate 'events' value is an array, not just that the key exists; throws AcquiaCliException for scalar/null events values - migrateServices: warn and skip image when PHP service has no version specified instead of producing invalid 'php:' image name - Add non-serial tests for both new code paths to preserve 100% covered MSI Co-Authored-By: Claude Sonnet 4.6 --- .../PipelinesMigrateGitlabCommand.php | 7 +++++- .../PipelinesMigrateGitlabCommandTest.php | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php index 4b49ea77..52244165 100644 --- a/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php +++ b/src/Command/Pipelines/PipelinesMigrateGitlabCommand.php @@ -122,6 +122,9 @@ private function parseSourceFile(string $path): array 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; } @@ -187,7 +190,9 @@ private function migrateServices(array $contents): array } match ($name) { - 'php' => $output['image'] = 'php:' . $version, + '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."), diff --git a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php index 25206fdf..ef8e7b64 100644 --- a/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php +++ b/tests/phpunit/src/Commands/Pipelines/PipelinesMigrateGitlabCommandTest.php @@ -356,6 +356,28 @@ public function testInvalidYamlExceptionContainsBothMessageAndParseError(): void } } + 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");